[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 <hannah.wright@elastic.co>
This commit is contained in:
Rachel Shen 2024-09-19 11:38:21 -06:00 committed by GitHub
parent 92b2c03247
commit 6030231246
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 160 additions and 7 deletions

View file

@ -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 <div>Test renderer</div>;
});
});
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(<DashboardApp redirectTo={jest.fn()} history={mockHistory} />);
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(<DashboardApp redirectTo={jest.fn()} history={mockHistory} expandedPanelId="456" />);
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');
});
});
});

View file

@ -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<boolean>(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({
<DashboardRenderer
key={regenerateId}
locator={locator}
onApiAvailable={setDashboardApi}
onApiAvailable={(dashboard) => {
if (dashboard && !dashboardApi) {
setDashboardApi(dashboard);
if (expandedPanelId) {
dashboard?.expandPanel(expandedPanelId);
}
}
}}
dashboardRedirect={redirectTo}
savedObjectId={savedDashboardId}
showPlainSpinner={showPlainSpinner}

View file

@ -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({
<HashRouter>
<Routes>
<Route
path={[CREATE_NEW_DASHBOARD_URL, `${VIEW_DASHBOARD_URL}/:id`]}
path={[
CREATE_NEW_DASHBOARD_URL,
`${VIEW_DASHBOARD_URL}/:id/:expandedPanelId`,
`${VIEW_DASHBOARD_URL}/:id`,
]}
render={renderDashboard}
/>
<Route exact path={LANDING_PAGE_PATH} render={renderListingPage} />

View file

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

View file

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