Skip to content

Commit

Permalink
[Dashboard] Sharing via link to an expanded panel (elastic#190086)
Browse files Browse the repository at this point in the history
## Summary

Closes elastic#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 <[email protected]>
(cherry picked from commit 6030231)
  • Loading branch information
rshen91 committed Sep 19, 2024
1 parent 446d593 commit 3a3318d
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 7 deletions.
98 changes: 98 additions & 0 deletions src/plugins/dashboard/public/dashboard_app/dashboard_app.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
19 changes: 17 additions & 2 deletions src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,13 +53,15 @@ export interface DashboardAppProps {
savedDashboardId?: string;
redirectTo: DashboardRedirect;
embedSettings?: DashboardEmbedSettings;
expandedPanelId?: string;
}

export function DashboardApp({
savedDashboardId,
embedSettings,
redirectTo,
history,
expandedPanelId,
}: DashboardAppProps) {
const [showNoDataPage, setShowNoDataPage] = useState<boolean>(false);
const [regenerateId, setRegenerateId] = useState(uuidv4());
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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}
Expand Down
11 changes: 9 additions & 2 deletions src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -112,6 +114,7 @@ export async function mountApp({
embedSettings={globalEmbedSettings}
savedDashboardId={routeProps.match.params.id}
redirectTo={redirect}
expandedPanelId={routeProps.match.params.expandedPanelId}
/>
);
};
Expand Down Expand Up @@ -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} />
Expand Down
27 changes: 26 additions & 1 deletion src/plugins/dashboard/public/dashboard_app/url/url_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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 };
};
12 changes: 10 additions & 2 deletions test/functional/page_objects/dashboard_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down

0 comments on commit 3a3318d

Please sign in to comment.