Skip to content

Commit

Permalink
[Dashboard] Rebuild State Management (#97941)
Browse files Browse the repository at this point in the history
* Rebuilt dashboard state management system with RTK.
  • Loading branch information
ThomThomson authored Jun 7, 2021
1 parent 1cd88f4 commit b3ed014
Show file tree
Hide file tree
Showing 80 changed files with 3,176 additions and 3,546 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@
"jest-cli": "^26.6.3",
"jest-diff": "^26.6.2",
"jest-environment-jsdom": "^26.6.2",
"jest-environment-jsdom-thirteen": "^1.0.1",
"jest-raw-loader": "^1.0.1",
"jest-silent-reporter": "^0.5.0",
"jest-snapshot": "^26.6.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@
* Side Public License, v 1.
*/

import { dashboardExpandPanelAction } from '../../dashboard_strings';
import { DashboardContainerInput } from '../..';
import { IEmbeddable } from '../../services/embeddable';
import { dashboardExpandPanelAction } from '../../dashboard_strings';
import { Action, IncompatibleActionError } from '../../services/ui_actions';
import {
DASHBOARD_CONTAINER_TYPE,
DashboardContainer,
DashboardContainerInput,
} from '../embeddable';
import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable';

export const ACTION_EXPAND_PANEL = 'togglePanel';

Expand Down
317 changes: 51 additions & 266 deletions src/plugins/dashboard/public/application/dashboard_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,20 @@
*/

import { History } from 'history';
import { merge, Subject, Subscription } from 'rxjs';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo } from 'react';

import { debounceTime, finalize, switchMap, tap } from 'rxjs/operators';
import { useDashboardSelector } from './state';
import { useDashboardAppState } from './hooks';
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,
getSearchSessionIdFromURL,
} from './dashboard_app_functions';
import {
useDashboardBreadcrumbs,
useDashboardContainer,
useDashboardStateManager,
useSavedDashboard,
} from './hooks';

import { IndexPattern, waitUntilNextSessionCompletes$ } from '../services/data';
getDashboardBreadcrumb,
getDashboardTitle,
leaveConfirmStrings,
} from '../dashboard_strings';
import { EmbeddableRenderer } from '../services/embeddable';
import { DashboardContainerInput } from '.';
import { leaveConfirmStrings } from '../dashboard_strings';
import { createQueryParamObservable, replaceUrlHashQuery } from '../../../kibana_utils/public';

import { DashboardTopNav, isCompleteDashboardAppState } from './top_nav/dashboard_top_nav';
import { DashboardAppServices, DashboardEmbedSettings, DashboardRedirect } from '../types';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '../services/kibana_utils';
export interface DashboardAppProps {
history: History;
savedDashboardId?: string;
Expand All @@ -50,236 +35,37 @@ export function DashboardApp({
history,
}: DashboardAppProps) {
const {
data,
core,
chrome,
embeddable,
onAppLeave,
uiSettings,
embeddable,
dashboardCapabilities,
indexPatterns: indexPatternService,
} = useKibana<DashboardAppServices>().services;

const triggerRefresh$ = useMemo(() => new Subject<{ force?: boolean }>(), []);
const [indexPatterns, setIndexPatterns] = useState<IndexPattern[]>([]);

const savedDashboard = useSavedDashboard(savedDashboardId, history);

const getIncomingEmbeddable = useCallback(
(removeAfterFetch?: boolean) => {
return embeddable
.getStateTransfer()
.getIncomingEmbeddablePackage(DashboardConstants.DASHBOARDS_ID, removeAfterFetch);
},
[embeddable]
const kbnUrlStateStorage = useMemo(
() =>
createKbnUrlStateStorage({
history,
useHash: uiSettings.get('state:storeInSessionStorage'),
...withNotifyOnErrors(core.notifications.toasts),
}),
[core.notifications.toasts, history, uiSettings]
);

const { dashboardStateManager, viewMode, setViewMode } = useDashboardStateManager(
savedDashboard,
history,
getIncomingEmbeddable
);
const [unsavedChanges, setUnsavedChanges] = useState(false);
const dashboardContainer = useDashboardContainer({
timeFilter: data.query.timefilter.timefilter,
dashboardStateManager,
getIncomingEmbeddable,
setUnsavedChanges,
const dashboardState = useDashboardSelector((state) => state.dashboardStateReducer);
const dashboardAppState = useDashboardAppState({
history,
redirectTo,
savedDashboardId,
kbnUrlStateStorage,
isEmbeddedExternally: Boolean(embedSettings),
});
const searchSessionIdQuery$ = useMemo(
() => createQueryParamObservable(history, DashboardConstants.SEARCH_SESSION_ID),
[history]
);

const refreshDashboardContainer = useCallback(
(force?: boolean) => {
if (!dashboardContainer || !dashboardStateManager) {
return;
}

const changes = getChangesFromAppStateForContainerState({
dashboardContainer,
appStateDashboardInput: getDashboardContainerInput({
isEmbeddedExternally: Boolean(embedSettings),
dashboardStateManager,
lastReloadRequestTime: force ? Date.now() : undefined,
dashboardCapabilities,
query: data.query,
}),
});

if (changes) {
// state keys change in which likely won't need a data fetch
const noRefetchKeys: Array<keyof DashboardContainerInput> = [
'viewMode',
'title',
'description',
'expandedPanelId',
'useMargins',
'isEmbeddedExternally',
'isFullScreenMode',
];
const shouldRefetch = Object.keys(changes).some(
(changeKey) => !noRefetchKeys.includes(changeKey as keyof DashboardContainerInput)
);

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

dashboardContainer.updateInput({
...changes,
...(newSearchSessionId && { searchSessionId: newSearchSessionId }),
});
}
},
[
history,
data.query,
setViewMode,
embedSettings,
dashboardContainer,
data.search.session,
dashboardCapabilities,
dashboardStateManager,
]
);

// Manage dashboard container subscriptions
useEffect(() => {
if (!dashboardStateManager || !dashboardContainer) {
return;
}
const timeFilter = data.query.timefilter.timefilter;
const subscriptions = new Subscription();

subscriptions.add(
getInputSubscription({
dashboardContainer,
dashboardStateManager,
filterManager: data.query.filterManager,
})
);
subscriptions.add(
getOutputSubscription({
dashboardContainer,
indexPatterns: indexPatternService,
onUpdateIndexPatterns: (newIndexPatterns) => setIndexPatterns(newIndexPatterns),
})
);
subscriptions.add(
getFiltersSubscription({
query: data.query,
dashboardStateManager,
})
);
subscriptions.add(
merge(
...[timeFilter.getRefreshIntervalUpdate$(), timeFilter.getTimeUpdate$()]
).subscribe(() => triggerRefresh$.next())
);

subscriptions.add(
searchSessionIdQuery$.subscribe(() => {
triggerRefresh$.next({ force: true });
})
);

subscriptions.add(
data.query.timefilter.timefilter
.getAutoRefreshFetch$()
.pipe(
tap(() => {
triggerRefresh$.next({ force: true });
}),
switchMap((done) =>
// best way on a dashboard to estimate that panels are updated is to rely on search session service state
waitUntilNextSessionCompletes$(data.search.session).pipe(finalize(done))
)
)
.subscribe()
);

dashboardStateManager.registerChangeListener(() => {
setUnsavedChanges(dashboardStateManager.getIsDirty(data.query.timefilter.timefilter));
// 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.
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();
};
}, [
core.http,
uiSettings,
data.query,
dashboardContainer,
data.search.session,
indexPatternService,
dashboardStateManager,
searchSessionIdQuery$,
triggerRefresh$,
refreshDashboardContainer,
]);

// Sync breadcrumbs when Dashboard State Manager changes
useDashboardBreadcrumbs(dashboardStateManager, redirectTo);

// Build onAppLeave when Dashboard State Manager changes
// Build app leave handler whenever hasUnsavedChanges changes
useEffect(() => {
if (!dashboardStateManager || !dashboardContainer) {
return;
}
onAppLeave((actions) => {
if (
dashboardStateManager?.getIsDirty() &&
dashboardAppState.hasUnsavedChanges &&
!embeddable.getStateTransfer().isTransferInProgress
) {
return actions.confirm(
Expand All @@ -293,37 +79,36 @@ export function DashboardApp({
// reset on app leave handler so leaving from the listing page doesn't trigger a confirmation
onAppLeave((actions) => actions.default());
};
}, [dashboardStateManager, dashboardContainer, onAppLeave, embeddable]);
}, [onAppLeave, embeddable, dashboardAppState.hasUnsavedChanges]);

// Set breadcrumbs when dashboard's title or view mode changes
useEffect(() => {
if (!dashboardState.title && savedDashboardId) return;
chrome.setBreadcrumbs([
{
text: getDashboardBreadcrumb(),
'data-test-subj': 'dashboardListingBreadcrumb',
onClick: () => {
redirectTo({ destination: 'listing' });
},
},
{
text: getDashboardTitle(dashboardState.title, dashboardState.viewMode, !savedDashboardId),
},
]);
}, [chrome, dashboardState.title, dashboardState.viewMode, redirectTo, savedDashboardId]);

return (
<>
{savedDashboard && dashboardStateManager && dashboardContainer && viewMode && (
{isCompleteDashboardAppState(dashboardAppState) && (
<>
<DashboardTopNav
{...{
redirectTo,
embedSettings,
indexPatterns,
savedDashboard,
unsavedChanges,
dashboardContainer,
dashboardStateManager,
}}
viewMode={viewMode}
lastDashboardId={savedDashboardId}
clearUnsavedChanges={() => setUnsavedChanges(false)}
timefilter={data.query.timefilter.timefilter}
onQuerySubmit={(_payload, isUpdate) => {
if (isUpdate === false) {
// 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.
triggerRefresh$.next({ force: true });
}
}}
redirectTo={redirectTo}
embedSettings={embedSettings}
dashboardAppState={dashboardAppState}
/>
<div className="dashboardViewport">
<EmbeddableRenderer embeddable={dashboardContainer} />
<EmbeddableRenderer embeddable={dashboardAppState.dashboardContainer} />
</div>
</>
)}
Expand Down
Loading

0 comments on commit b3ed014

Please sign in to comment.