From 3a3318d8385aedd806dfff3263ddc264082c5598 Mon Sep 17 00:00:00 2001 From: Rachel Shen Date: Thu, 19 Sep 2024 11:38:21 -0600 Subject: [PATCH] [Dashboard] Sharing via link to an expanded panel (#190086) ## Summary Closes https://github.com/elastic/kibana/issues/145454 This PR allows users with a dashboard with an expanded (maximized) panel to be shared to other users via url or the share modal link. To implement this, the expanded panel Id is added to the url: `/app/dashboard/{dashboardID}/{expandedPanelID}_g()_a()` ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios Co-authored-by: Hannah Mudge (cherry picked from commit 603023124681429ee900ff3a73793ce31a9cad58) --- .../dashboard_app/dashboard_app.test.tsx | 98 +++++++++++++++++++ .../public/dashboard_app/dashboard_app.tsx | 19 +++- .../public/dashboard_app/dashboard_router.tsx | 11 ++- .../public/dashboard_app/url/url_utils.ts | 27 ++++- .../functional/page_objects/dashboard_page.ts | 12 ++- 5 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 src/plugins/dashboard/public/dashboard_app/dashboard_app.test.tsx 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; }