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