diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_app.test.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_app.test.tsx
new file mode 100644
index 0000000000000..f04528e7bde2c
--- /dev/null
+++ b/src/plugins/dashboard/public/dashboard_app/dashboard_app.test.tsx
@@ -0,0 +1,98 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the "Elastic License
+ * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
+ * Public License v 1"; you may not use this file except in compliance with, at
+ * your election, the "Elastic License 2.0", the "GNU Affero General Public
+ * License v3.0 only", or the "Server Side Public License, v 1".
+ */
+
+import { render, waitFor } from '@testing-library/react';
+import { MemoryHistory, createMemoryHistory } from 'history';
+import React, { useEffect } from 'react';
+import type { DashboardRendererProps } from '../dashboard_container/external_api/dashboard_renderer';
+import { buildMockDashboard } from '../mocks';
+import { DashboardApp } from './dashboard_app';
+
+import * as dashboardRendererStuff from '../dashboard_container/external_api/lazy_dashboard_renderer';
+import { DashboardApi } from '..';
+
+/* These tests circumvent the need to test the router and legacy code
+/* the dashboard app will be passed the expanded panel id from the DashboardRouter through mountApp()
+/* @link https://github.com/elastic/kibana/pull/190086/
+*/
+
+describe('Dashboard App', () => {
+ const mockDashboard = buildMockDashboard();
+ let mockHistory: MemoryHistory;
+ // this is in url_utils dashboardApi expandedPanel subscription
+ let historySpy: jest.SpyInstance;
+ // this is in the dashboard app for the renderer when provided an expanded panel id
+ const expandPanelSpy = jest.spyOn(mockDashboard, 'expandPanel');
+
+ beforeAll(() => {
+ mockHistory = createMemoryHistory();
+ historySpy = jest.spyOn(mockHistory, 'replace');
+
+ /**
+ * Mock the LazyDashboardRenderer component to avoid rendering the actual dashboard
+ * and hitting errors that aren't relevant
+ */
+ jest
+ .spyOn(dashboardRendererStuff, 'LazyDashboardRenderer')
+ // we need overwrite the onApiAvailable prop to get the dashboard Api in the dashboard app
+ .mockImplementation(({ onApiAvailable }: DashboardRendererProps) => {
+ useEffect(() => {
+ onApiAvailable?.(mockDashboard as DashboardApi);
+ }, [onApiAvailable]);
+
+ return
Test renderer
;
+ });
+ });
+
+ beforeEach(() => {
+ // reset the spies before each test
+ expandPanelSpy.mockClear();
+ historySpy.mockClear();
+ });
+
+ it('test the default behavior without an expandedPanel id passed as a prop to the DashboardApp', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(expandPanelSpy).not.toHaveBeenCalled();
+ // this value should be undefined by default
+ expect(mockDashboard.expandedPanelId.getValue()).toBe(undefined);
+ // history should not be called
+ expect(historySpy).toHaveBeenCalledTimes(0);
+ expect(mockHistory.location.pathname).toBe('/');
+ });
+
+ // simulate expanding a panel
+ mockDashboard.expandPanel('123');
+
+ await waitFor(() => {
+ expect(mockDashboard.expandedPanelId.getValue()).toBe('123');
+ expect(historySpy).toHaveBeenCalledTimes(1);
+ expect(mockHistory.location.pathname).toBe('/create/123');
+ });
+ });
+
+ it('test that the expanded panel behavior subject and history is called when passed as a prop to the DashboardApp', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(expandPanelSpy).toHaveBeenCalledTimes(1);
+ expect(historySpy).toHaveBeenCalledTimes(0);
+ });
+
+ // simulate minimizing a panel
+ mockDashboard.expandedPanelId.next(undefined);
+
+ await waitFor(() => {
+ expect(mockDashboard.expandedPanelId.getValue()).toBe(undefined);
+ expect(historySpy).toHaveBeenCalledTimes(1);
+ expect(mockHistory.location.pathname).toBe('/create');
+ });
+ });
+});
diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx
index fa1b505879090..759d817432c6a 100644
--- a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx
+++ b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx
@@ -23,7 +23,7 @@ import {
DashboardAppNoDataPage,
isDashboardAppInNoDataState,
} from './no_data/dashboard_app_no_data';
-import { loadAndRemoveDashboardState } from './url/url_utils';
+import { loadAndRemoveDashboardState, startSyncingExpandedPanelState } from './url/url_utils';
import {
getSessionURLObservable,
getSearchSessionIdFromURL,
@@ -53,6 +53,7 @@ export interface DashboardAppProps {
savedDashboardId?: string;
redirectTo: DashboardRedirect;
embedSettings?: DashboardEmbedSettings;
+ expandedPanelId?: string;
}
export function DashboardApp({
@@ -60,6 +61,7 @@ export function DashboardApp({
embedSettings,
redirectTo,
history,
+ expandedPanelId,
}: DashboardAppProps) {
const [showNoDataPage, setShowNoDataPage] = useState(false);
const [regenerateId, setRegenerateId] = useState(uuidv4());
@@ -183,6 +185,12 @@ export function DashboardApp({
getScreenshotContext,
]);
+ useEffect(() => {
+ if (!dashboardApi) return;
+ const { stopWatchingExpandedPanel } = startSyncingExpandedPanelState({ dashboardApi, history });
+ return () => stopWatchingExpandedPanel();
+ }, [dashboardApi, history]);
+
/**
* When the dashboard container is created, or re-created, start syncing dashboard state with the URL
*/
@@ -221,7 +229,14 @@ export function DashboardApp({
{
+ if (dashboard && !dashboardApi) {
+ setDashboardApi(dashboard);
+ if (expandedPanelId) {
+ dashboard?.expandPanel(expandedPanelId);
+ }
+ }
+ }}
dashboardRedirect={redirectTo}
savedObjectId={savedDashboardId}
showPlainSpinner={showPlainSpinner}
diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx
index 2c4651c3b2901..8449ed9de1080 100644
--- a/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx
+++ b/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx
@@ -101,7 +101,9 @@ export async function mountApp({
};
};
- const renderDashboard = (routeProps: RouteComponentProps<{ id?: string }>) => {
+ const renderDashboard = (
+ routeProps: RouteComponentProps<{ id?: string; expandedPanelId?: string }>
+ ) => {
const routeParams = parse(routeProps.history.location.search);
if (routeParams.embed === 'true' && !globalEmbedSettings) {
globalEmbedSettings = getDashboardEmbedSettings(routeParams);
@@ -112,6 +114,7 @@ export async function mountApp({
embedSettings={globalEmbedSettings}
savedDashboardId={routeProps.match.params.id}
redirectTo={redirect}
+ expandedPanelId={routeProps.match.params.expandedPanelId}
/>
);
};
@@ -154,7 +157,11 @@ export async function mountApp({
diff --git a/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts b/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts
index 7fc7d17c55053..09e15abe5eea1 100644
--- a/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts
+++ b/src/plugins/dashboard/public/dashboard_app/url/url_utils.ts
@@ -8,7 +8,9 @@
*/
import _ from 'lodash';
+import { skip } from 'rxjs';
import semverSatisfies from 'semver/functions/satisfies';
+import { History } from 'history';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/common';
@@ -21,9 +23,10 @@ import {
} from '../../../common';
import { pluginServices } from '../../services/plugin_services';
import { getPanelTooOldErrorString } from '../_dashboard_app_strings';
-import { DASHBOARD_STATE_STORAGE_KEY } from '../../dashboard_constants';
+import { DASHBOARD_STATE_STORAGE_KEY, createDashboardEditUrl } from '../../dashboard_constants';
import { SavedDashboardPanel } from '../../../common/content_management';
import { migrateLegacyQuery } from '../../services/dashboard_content_management/lib/load_dashboard_state';
+import { DashboardApi } from '../../dashboard_api/types';
/**
* We no longer support loading panels from a version older than 7.3 in the URL.
@@ -84,3 +87,25 @@ export const loadAndRemoveDashboardState = (
return partialState;
};
+
+export const startSyncingExpandedPanelState = ({
+ dashboardApi,
+ history,
+}: {
+ dashboardApi: DashboardApi;
+ history: History;
+}) => {
+ const expandedPanelSubscription = dashboardApi?.expandedPanelId
+ // skip the first value because we don't want to trigger a history.replace on initial load
+ .pipe(skip(1))
+ .subscribe((expandedPanelId) => {
+ history.replace({
+ ...history.location,
+ pathname: `${createDashboardEditUrl(dashboardApi.savedObjectId.value)}${
+ Boolean(expandedPanelId) ? `/${expandedPanelId}` : ''
+ }`,
+ });
+ });
+ const stopWatchingExpandedPanel = () => expandedPanelSubscription.unsubscribe();
+ return { stopWatchingExpandedPanel };
+};
diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts
index c610b417c4323..f6a3aec2eacd5 100644
--- a/test/functional/page_objects/dashboard_page.ts
+++ b/test/functional/page_objects/dashboard_page.ts
@@ -154,8 +154,16 @@ export class DashboardPageObject extends FtrService {
public getDashboardIdFromUrl(url: string) {
const urlSubstring = '#/view/';
const startOfIdIndex = url.indexOf(urlSubstring) + urlSubstring.length;
- const endIndex = url.indexOf('?');
- const id = url.substring(startOfIdIndex, endIndex < 0 ? url.length : endIndex);
+ const endIndexOfFilters = url.indexOf('?');
+ const endIndexOfMax = url.substring(startOfIdIndex).indexOf('/');
+ if (endIndexOfMax === -1) {
+ return url.substring(startOfIdIndex, endIndexOfFilters);
+ }
+ const endIndex =
+ endIndexOfFilters + startOfIdIndex > endIndexOfMax
+ ? endIndexOfFilters + startOfIdIndex
+ : endIndexOfMax + startOfIdIndex;
+ const id = url.substring(startOfIdIndex, endIndex < 0 ? url.length : endIndex + startOfIdIndex);
return id;
}