From 5e2ac24890906e5070323d87730dd78a4f885963 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Mon, 9 Sep 2024 14:45:05 +0200 Subject: [PATCH] Sidecar: Add split view and basic APIs for extensions (#91648) * Add split view and basic APIs to extensions * Add comments * Update public/app/AppWrapper.tsx Co-authored-by: Levente Balogh * Moved the .grafana-app element and deduplicate some code * Remove the provider variants of usePluginLinks/Components * Change buildPluginSectionNav * Update comment * Use eventBus * Remove non existent exports * refactor: use a sidecar service to encapsulate the state * Don't wrap single app in split wrapper * Use hook splitter * Remove inline styles * Type the style props from useSplitter * Move the overflow style changes to appWrapper * Deduplicate some common top level providers * Move modals * Move routes wrappers to it's own file * Use better css and add comments * Remove query rows app extension point * Fix test --------- Co-authored-by: Levente Balogh --- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + .../src/types/pluginExtensions.ts | 23 ++- .../pluginExtensions/getPluginExtensions.ts | 1 + .../src/components/Splitter/useSplitter.ts | 20 ++- pkg/services/featuremgmt/registry.go | 6 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 14 +- public/app/AppWrapper.tsx | 68 +++----- public/app/core/context/SidecarContext.ts | 22 +++ public/app/core/services/SidecarService.ts | 74 +++++++++ .../getExploreExtensionConfigs.test.tsx | 4 +- .../plugins/components/AppRootPage.tsx | 7 +- .../extensions/getPluginExtensions.test.tsx | 5 +- .../plugins/extensions/getPluginExtensions.ts | 12 +- .../extensions/usePluginExtensions.tsx | 7 + .../plugins/extensions/usePluginLinks.tsx | 6 +- .../plugins/extensions/utils.test.tsx | 153 +++++++++--------- .../app/features/plugins/extensions/utils.tsx | 31 ++-- .../plugins/extensions/validators.test.tsx | 2 +- public/app/features/plugins/utils.test.ts | 13 +- public/app/features/plugins/utils.ts | 9 +- public/app/routes/RoutesWrapper.tsx | 150 +++++++++++++++++ 24 files changed, 459 insertions(+), 175 deletions(-) create mode 100644 public/app/core/context/SidecarContext.ts create mode 100644 public/app/core/services/SidecarService.ts create mode 100644 public/app/routes/RoutesWrapper.tsx diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 150b4273f92..d50f0bb7f26 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -198,6 +198,7 @@ Experimental features might be changed or removed without prior notice. | `exploreLogsShardSplitting` | Used in Explore Logs to split queries into multiple queries based on the number of shards | | `exploreLogsAggregatedMetrics` | Used in Explore Logs to query by aggregated metrics | | `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range | +| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c79f0418045..27eeb7769cc 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -208,4 +208,5 @@ export interface FeatureToggles { exploreLogsAggregatedMetrics?: boolean; exploreLogsLimitedTimeRange?: boolean; appPlatformAccessTokens?: boolean; + appSidecar?: boolean; } diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index bdb3e06b100..54c1170681f 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -75,7 +75,10 @@ export type PluginExtensionAddedComponentConfig = PluginExtensionCon component: React.ComponentType; }; -export type PluginAddedLinksConfigureFunc = (context?: Readonly) => +export type PluginAddedLinksConfigureFunc = ( + context: Readonly | undefined, + helpers: PluginExtensionHelpers +) => | Partial<{ title: string; description: string; @@ -142,11 +145,27 @@ export type PluginExtensionOpenModalOptions = { height?: string | number; }; +type PluginExtensionHelpers = { + /** Checks if the app plugin (that registers the extension) is currently visible (either in the main view or in the side view) + * @experimental + */ + isAppOpened: () => boolean; +}; + export type PluginExtensionEventHelpers = { context?: Readonly; // Opens a modal dialog and renders the provided React component inside it openModal: (options: PluginExtensionOpenModalOptions) => void; -}; + + /** Opens the app plugin (that registers the extensions) in a side view + * @experimental + */ + openAppInSideview: (context?: unknown) => void; + /** Closes the side view for the app plugin (that registers the extensions) in case it was open + * @experimental + */ + closeAppInSideview: () => void; +} & PluginExtensionHelpers; // Extension Points & Contexts // -------------------------------------------------------- diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index cf6d48d7e6b..be151b05858 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -12,6 +12,7 @@ export type UsePluginExtensions = ( export type GetPluginExtensionsOptions = { extensionPointId: string; + // Make sure this object is properly memoized and not mutated. context?: object | Record; limitPerPlugin?: number; }; diff --git a/packages/grafana-ui/src/components/Splitter/useSplitter.ts b/packages/grafana-ui/src/components/Splitter/useSplitter.ts index 0ba04256876..325087bf683 100644 --- a/packages/grafana-ui/src/components/Splitter/useSplitter.ts +++ b/packages/grafana-ui/src/components/Splitter/useSplitter.ts @@ -300,6 +300,16 @@ export function useSplitter(options: UseSplitterOptions) { const dragHandleStyle = direction === 'column' ? dragStyles.dragHandleHorizontal : dragStyles.dragHandleVertical; const id = useId(); + const primaryStyles: React.CSSProperties = { + flexGrow: clamp(initialSize ?? 0.5, 0, 1), + [minDimProp]: 'min-content', + }; + + const secondaryStyles: React.CSSProperties = { + flexGrow: clamp(1 - initialSize, 0, 1), + [minDimProp]: 'min-content', + }; + return { containerProps: { ref: containerRef, @@ -308,18 +318,12 @@ export function useSplitter(options: UseSplitterOptions) { primaryProps: { ref: firstPaneRef, className: styles.panel, - style: { - [minDimProp]: 'min-content', - flexGrow: clamp(initialSize ?? 0.5, 0, 1), - }, + style: primaryStyles, }, secondaryProps: { ref: secondPaneRef, className: styles.panel, - style: { - flexGrow: clamp(1 - initialSize, 0, 1), - [minDimProp]: 'min-content', - }, + style: secondaryStyles, }, splitterProps: { onPointerUp, diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index a7f645e3aa2..b4eae84b66a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1434,6 +1434,12 @@ var ( HideFromDocs: true, HideFromAdminPage: true, }, + { + Name: "appSidecar", + Description: "Enable the app sidecar feature that allows rendering 2 apps at the same time", + Stage: FeatureStageExperimental, + Owner: grafanaExploreSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 0ad5a6f19a1..7cf0306e768 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -189,3 +189,4 @@ exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,t exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true appPlatformAccessTokens,experimental,@grafana/identity-access-team,false,false,false +appSidecar,experimental,@grafana/explore-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 544f2d40fd7..e0a5c7415c4 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -766,4 +766,8 @@ const ( // FlagAppPlatformAccessTokens // Enables the use of access tokens for the App Platform FlagAppPlatformAccessTokens = "appPlatformAccessTokens" + + // FlagAppSidecar + // Enable the app sidecar feature that allows rendering 2 apps at the same time + FlagAppSidecar = "appSidecar" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 46e9189f4c6..8e6f4795d46 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -350,6 +350,18 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "appSidecar", + "resourceVersion": "1722872007883", + "creationTimestamp": "2024-08-05T15:33:27Z" + }, + "spec": { + "description": "Enable the app sidecar feature that allows rendering 2 apps at the same time", + "stage": "experimental", + "codeowner": "@grafana/explore-squad" + } + }, { "metadata": { "name": "authAPIAccessTokenAuth", @@ -2859,4 +2871,4 @@ } } ] -} \ No newline at end of file +} diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index b73839203c2..0cbe1f21d78 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -1,32 +1,25 @@ import { Action, KBarProvider } from 'kbar'; import { Component, ComponentType } from 'react'; import { Provider } from 'react-redux'; -import { Router, Redirect, Switch, RouteComponentProps } from 'react-router-dom'; -import { CompatRouter, CompatRoute } from 'react-router-dom-v5-compat'; - -import { - config, - locationService, - LocationServiceProvider, - navigationLogger, - reportInteraction, -} from '@grafana/runtime'; -import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, PortalContainer, Stack } from '@grafana/ui'; +import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'; +import { CompatRoute } from 'react-router-dom-v5-compat'; + +import { config, navigationLogger, reportInteraction } from '@grafana/runtime'; +import { ErrorBoundaryAlert, GlobalStyles, PortalContainer } from '@grafana/ui'; import { getAppRoutes } from 'app/routes/routes'; import { store } from 'app/store/store'; -import { AngularRoot } from './angular/AngularRoot'; import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled'; import { GrafanaApp } from './app'; -import { AppChrome } from './core/components/AppChrome/AppChrome'; -import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList'; import { GrafanaContext } from './core/context/GrafanaContext'; -import { ModalsContextProvider } from './core/context/ModalsContextProvider'; +import { SidecarContext } from './core/context/SidecarContext'; import { GrafanaRoute } from './core/navigation/GrafanaRoute'; import { RouteDescriptor } from './core/navigation/types'; +import { sidecarService } from './core/services/SidecarService'; import { contextSrv } from './core/services/context_srv'; import { ThemeProvider } from './core/utils/ConfigProvider'; import { LiveConnectionWarning } from './features/live/LiveConnectionWarning'; +import { ExperimentalSplitPaneRouterWrapper, RouterWrapper } from './routes/RoutesWrapper'; interface AppWrapperProps { app: GrafanaApp; @@ -100,6 +93,12 @@ export class AppWrapper extends Component { }); }; + const routerWrapperProps = { + routes: ready && this.renderRoutes(), + pageBanners, + bodyRenderHooks, + }; + return ( @@ -109,33 +108,18 @@ export class AppWrapper extends Component { actions={[]} options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }} > - - - - - -
- - - - - {pageBanners.map((Banner, index) => ( - - ))} - {ready && this.renderRoutes()} - - {bodyRenderHooks.map((Hook, index) => ( - - ))} - -
- - - -
-
-
-
+ + +
+ {config.featureToggles.appSidecar ? ( + + ) : ( + + )} + + +
+
diff --git a/public/app/core/context/SidecarContext.ts b/public/app/core/context/SidecarContext.ts new file mode 100644 index 00000000000..c6b341295c6 --- /dev/null +++ b/public/app/core/context/SidecarContext.ts @@ -0,0 +1,22 @@ +import { createContext, useContext } from 'react'; +import { useObservable } from 'react-use'; + +import { SidecarService, sidecarService } from '../services/SidecarService'; + +export const SidecarContext = createContext(sidecarService); + +export function useSidecar() { + const activePluginId = useObservable(sidecarService.activePluginId); + const context = useContext(SidecarContext); + + if (!context) { + throw new Error('No SidecarContext found'); + } + + return { + activePluginId, + openApp: (pluginId: string) => context.openApp(pluginId), + closeApp: (pluginId: string) => context.closeApp(pluginId), + isAppOpened: (pluginId: string) => context.isAppOpened(pluginId), + }; +} diff --git a/public/app/core/services/SidecarService.ts b/public/app/core/services/SidecarService.ts new file mode 100644 index 00000000000..08005da697b --- /dev/null +++ b/public/app/core/services/SidecarService.ts @@ -0,0 +1,74 @@ +import { BehaviorSubject } from 'rxjs'; + +import { config, locationService } from '@grafana/runtime'; + +export class SidecarService { + // The ID of the app plugin that is currently opened in the sidecar view + private _activePluginId: BehaviorSubject; + + constructor() { + this._activePluginId = new BehaviorSubject(undefined); + } + + private assertFeatureEnabled() { + if (!config.featureToggles.appSidecar) { + console.warn('The `appSidecar` feature toggle is not enabled, doing nothing.'); + return false; + } + + return true; + } + + get activePluginId() { + return this._activePluginId.asObservable(); + } + + openApp(pluginId: string) { + if (!this.assertFeatureEnabled()) { + return false; + } + + return this._activePluginId.next(pluginId); + } + + closeApp(pluginId: string) { + if (!this.assertFeatureEnabled()) { + return false; + } + + if (this._activePluginId.getValue() === pluginId) { + return this._activePluginId.next(undefined); + } + } + + isAppOpened(pluginId: string) { + if (!this.assertFeatureEnabled()) { + return false; + } + + if (this._activePluginId.getValue() === pluginId || getMainAppPluginId() === pluginId) { + return true; + } + + return false; + } +} + +export const sidecarService = new SidecarService(); + +// The app plugin that is "open" in the main Grafana view +function getMainAppPluginId() { + const { pathname } = locationService.getLocation(); + + // A naive way to sort of simulate core features being an app and having an appID + let mainApp = pathname.match(/\/a\/([^/]+)/)?.[1]; + if (!mainApp && pathname.match(/\/explore/)) { + mainApp = 'explore'; + } + + if (!mainApp && pathname.match(/\/d\//)) { + mainApp = 'dashboards'; + } + + return mainApp || 'unknown'; +} diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx index 5d197f79dde..869a00d8006 100644 --- a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx @@ -43,7 +43,7 @@ describe('getExploreExtensionConfigs', () => { const extensions = getExploreExtensionConfigs(); const [extension] = extensions; - expect(extension?.configure?.()).toBeUndefined(); + expect(extension?.configure?.(undefined, { isAppOpened: () => false })).toBeUndefined(); }); it('should return empty object if sufficient permissions', () => { @@ -52,7 +52,7 @@ describe('getExploreExtensionConfigs', () => { const extensions = getExploreExtensionConfigs(); const [extension] = extensions; - expect(extension?.configure?.()).toEqual({}); + expect(extension?.configure?.(undefined, { isAppOpened: () => false })).toEqual({}); }); }); }); diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index aa1d46b7491..c01bb2f4941 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -33,8 +33,9 @@ import { buildPluginPageContext, PluginPageContext } from './PluginPageContext'; interface Props { // The ID of the plugin we would like to load and display pluginId: string; - // The root navModelItem for the plugin (root = lives directly under 'home') - pluginNavSection: NavModelItem; + // The root navModelItem for the plugin (root = lives directly under 'home'). In case app does not need a nva model, + // for example it's in some way embedded or shown in a sideview this can be undefined. + pluginNavSection?: NavModelItem; } interface State { @@ -53,7 +54,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { const [state, dispatch] = useReducer(stateSlice.reducer, initialState); const currentUrl = config.appSubUrl + location.pathname + location.search; const { plugin, loading, loadingError, pluginNav } = state; - const navModel = buildPluginSectionNav(pluginNavSection, pluginNav, currentUrl); + const navModel = buildPluginSectionNav(currentUrl, pluginNavSection); const queryParams = useMemo(() => locationSearchToObject(location.search), [location.search]); const context = useMemo(() => buildPluginPageContext(navModel), [navModel]); const grafanaContext = useGrafana(); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx index b3af3582ea7..23d44c6f540 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -174,7 +174,10 @@ describe('getPluginExtensions()', () => { getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); expect(link2.configure).toHaveBeenCalledTimes(1); - expect(link2.configure).toHaveBeenCalledWith(context); + expect(link2.configure).toHaveBeenCalledWith( + context, + expect.objectContaining({ isAppOpened: expect.any(Function) }) + ); }); test('should be possible to update the basic properties with the configure() function', async () => { diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 241f26ca15f..bd7d55d6e0d 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -88,11 +88,7 @@ export const getPluginExtensions: GetExtensions = ({ const path = overrides?.path || addedLink.path; const extension: PluginExtensionLink = { - id: generateExtensionId(pluginId, { - ...addedLink, - extensionPointId, - type: PluginExtensionTypes.link, - }), + id: generateExtensionId(pluginId, extensionPointId, addedLink.title), type: PluginExtensionTypes.link, pluginId: pluginId, onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext), @@ -125,11 +121,7 @@ export const getPluginExtensions: GetExtensions = ({ extensionsByPlugin[addedComponent.pluginId] = 0; } const extension: PluginExtensionComponent = { - id: generateExtensionId(addedComponent.pluginId, { - ...addedComponent, - extensionPointId, - type: PluginExtensionTypes.component, - }), + id: generateExtensionId(addedComponent.pluginId, extensionPointId, addedComponent.title), type: PluginExtensionTypes.component, pluginId: addedComponent.pluginId, title: addedComponent.title, diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index 62e583ad857..f67c24ba5c5 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -3,6 +3,7 @@ import { useObservable } from 'react-use'; import { PluginExtension } from '@grafana/data'; import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime'; +import { useSidecar } from 'app/core/context/SidecarContext'; import { getPluginExtensions } from './getPluginExtensions'; import { PluginExtensionRegistries } from './registry/types'; @@ -14,6 +15,7 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries) return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult { const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry); const addedLinksRegistry = useObservable(observableAddedLinksRegistry); + const { activePluginId } = useSidecar(); const { extensions } = useMemo(() => { if (!addedLinksRegistry && !addedComponentsRegistry) { @@ -27,12 +29,17 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries) addedComponentsRegistry, addedLinksRegistry, }); + // Doing the deps like this instead of just `option` because users probably aren't going to memoize the + // options object so we are checking it's simple value attributes. + // The context though still has to be memoized though and not mutated. + // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: refactor `getPluginExtensions` to accept service dependencies as arguments instead of relying on the sidecar singleton under the hood }, [ addedLinksRegistry, addedComponentsRegistry, options.extensionPointId, options.context, options.limitPerPlugin, + activePluginId, ]); return { extensions, isLoading: false }; diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index 0350209636a..be8f3f5bf03 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -60,11 +60,7 @@ export function createUsePluginLinks(registry: AddedLinksRegistry) { const path = overrides?.path || addedLink.path; const extension: PluginExtensionLink = { - id: generateExtensionId(pluginId, { - ...addedLink, - extensionPointId, - type: PluginExtensionTypes.link, - }), + id: generateExtensionId(pluginId, extensionPointId, addedLink.title), type: PluginExtensionTypes.link, pluginId: pluginId, onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext), diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index f579138e76a..aca0f0797cf 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -5,7 +5,13 @@ import { dateTime, usePluginContext } from '@grafana/data'; import appEvents from 'app/core/app_events'; import { ShowModalReactEvent } from 'app/types/events'; -import { deepFreeze, handleErrorsInFn, getReadOnlyProxy, getEventHelpers, wrapWithPluginContext } from './utils'; +import { + deepFreeze, + handleErrorsInFn, + getReadOnlyProxy, + createOpenModalFunction, + wrapWithPluginContext, +} from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ ...jest.requireActual('app/features/plugins/pluginSettings'), @@ -306,111 +312,100 @@ describe('Plugin Extensions / Utils', () => { }); }); - describe('getEventHelpers', () => { - describe('openModal', () => { - let renderModalSubscription: Unsubscribable | undefined; + describe('createOpenModalFunction()', () => { + let renderModalSubscription: Unsubscribable | undefined; - beforeAll(() => { - renderModalSubscription = appEvents.subscribe(ShowModalReactEvent, (event) => { - const { payload } = event; - const Modal = payload.component; - render(); - }); - }); - - afterAll(() => { - renderModalSubscription?.unsubscribe(); + beforeAll(() => { + renderModalSubscription = appEvents.subscribe(ShowModalReactEvent, (event) => { + const { payload } = event; + const Modal = payload.component; + render(); }); + }); - it('should open modal with provided title and body', async () => { - const pluginId = 'grafana-worldmap-panel'; - const { openModal } = getEventHelpers(pluginId); + afterAll(() => { + renderModalSubscription?.unsubscribe(); + }); - openModal({ - title: 'Title in modal', - body: () =>
Text in body
, - }); + it('should open modal with provided title and body', async () => { + const pluginId = 'grafana-worldmap-panel'; + const openModal = createOpenModalFunction(pluginId); - expect(await screen.findByRole('dialog')).toBeVisible(); - expect(screen.getByRole('heading')).toHaveTextContent('Title in modal'); - expect(screen.getByText('Text in body')).toBeVisible(); + openModal({ + title: 'Title in modal', + body: () =>
Text in body
, }); - it('should open modal with default width if not specified', async () => { - const pluginId = 'grafana-worldmap-panel'; - const { openModal } = getEventHelpers(pluginId); - - openModal({ - title: 'Title in modal', - body: () =>
Text in body
, - }); + expect(await screen.findByRole('dialog')).toBeVisible(); + expect(screen.getByRole('heading')).toHaveTextContent('Title in modal'); + expect(screen.getByText('Text in body')).toBeVisible(); + }); - const modal = await screen.findByRole('dialog'); - const style = window.getComputedStyle(modal); + it('should open modal with default width if not specified', async () => { + const pluginId = 'grafana-worldmap-panel'; + const openModal = createOpenModalFunction(pluginId); - expect(style.width).toBe('750px'); - expect(style.height).toBe(''); + openModal({ + title: 'Title in modal', + body: () =>
Text in body
, }); - it('should open modal with specified width', async () => { - const pluginId = 'grafana-worldmap-panel'; - const { openModal } = getEventHelpers(pluginId); + const modal = await screen.findByRole('dialog'); + const style = window.getComputedStyle(modal); - openModal({ - title: 'Title in modal', - body: () =>
Text in body
, - width: '70%', - }); + expect(style.width).toBe('750px'); + expect(style.height).toBe(''); + }); - const modal = await screen.findByRole('dialog'); - const style = window.getComputedStyle(modal); + it('should open modal with specified width', async () => { + const pluginId = 'grafana-worldmap-panel'; + const openModal = createOpenModalFunction(pluginId); - expect(style.width).toBe('70%'); + openModal({ + title: 'Title in modal', + body: () =>
Text in body
, + width: '70%', }); - it('should open modal with specified height', async () => { - const pluginId = 'grafana-worldmap-panel'; - const { openModal } = getEventHelpers(pluginId); + const modal = await screen.findByRole('dialog'); + const style = window.getComputedStyle(modal); - openModal({ - title: 'Title in modal', - body: () =>
Text in body
, - height: 600, - }); + expect(style.width).toBe('70%'); + }); - const modal = await screen.findByRole('dialog'); - const style = window.getComputedStyle(modal); + it('should open modal with specified height', async () => { + const pluginId = 'grafana-worldmap-panel'; + const openModal = createOpenModalFunction(pluginId); - expect(style.height).toBe('600px'); + openModal({ + title: 'Title in modal', + body: () =>
Text in body
, + height: 600, }); - it('should open modal with the plugin context being available', async () => { - const pluginId = 'grafana-worldmap-panel'; - const { openModal } = getEventHelpers(pluginId); + const modal = await screen.findByRole('dialog'); + const style = window.getComputedStyle(modal); + + expect(style.height).toBe('600px'); + }); - const ModalContent = () => { - const context = usePluginContext(); + it('should open modal with the plugin context being available', async () => { + const pluginId = 'grafana-worldmap-panel'; + const openModal = createOpenModalFunction(pluginId); - return
Version: {context.meta.info.version}
; - }; + const ModalContent = () => { + const context = usePluginContext(); - openModal({ - title: 'Title in modal', - body: ModalContent, - }); + return
Version: {context.meta.info.version}
; + }; - const modal = await screen.findByRole('dialog'); - expect(modal).toHaveTextContent('Version: 1.0.0'); + openModal({ + title: 'Title in modal', + body: ModalContent, }); - }); - describe('context', () => { - it('should return same object as passed to getEventHelpers', () => { - const pluginId = 'grafana-worldmap-panel'; - const source = {}; - const { context } = getEventHelpers(pluginId, source); - expect(context).toBe(source); - }); + const modal = await screen.findByRole('dialog'); + expect(modal).toHaveTextContent('Version: 1.0.0'); }); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 8c3075dd402..ade21ad0962 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -20,6 +20,8 @@ import { import { reportInteraction } from '@grafana/runtime'; import { Modal } from '@grafana/ui'; import appEvents from 'app/core/app_events'; +// TODO: instead of depending on the service as a singleton, inject it as an argument from the React context +import { sidecarService } from 'app/core/services/SidecarService'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { ShowModalReactEvent } from 'app/types/events'; @@ -48,9 +50,8 @@ export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') { }; } -// Event helpers are designed to make it easier to trigger "core actions" from an extension event handler, e.g. opening a modal or showing a notification. -export function getEventHelpers(pluginId: string, context?: Readonly): PluginExtensionEventHelpers { - const openModal: PluginExtensionEventHelpers['openModal'] = async (options) => { +export function createOpenModalFunction(pluginId: string): PluginExtensionEventHelpers['openModal'] { + return async (options) => { const { title, body, width, height } = options; appEvents.publish( @@ -59,8 +60,6 @@ export function getEventHelpers(pluginId: string, context?: Readonly): P }) ); }; - - return { openModal, context }; } type ModalWrapperProps = { @@ -161,8 +160,8 @@ export function deepFreeze(value?: object | Record | u return Object.freeze(clonedValue); } -export function generateExtensionId(pluginId: string, extensionConfig: PluginExtensionConfig): string { - const str = `${pluginId}${extensionConfig.extensionPointId}${extensionConfig.title}`; +export function generateExtensionId(pluginId: string, extensionPointId: string, title: string): string { + const str = `${pluginId}${extensionPointId}${title}`; return Array.from(str) .reduce((s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0) @@ -298,7 +297,7 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel export function getLinkExtensionOverrides(pluginId: string, config: AddedLinkRegistryItem, context?: object) { try { - const overrides = config.configure?.(context); + const overrides = config.configure?.(context, { isAppOpened: () => isAppOpened(pluginId) }); // Hiding the extension if (overrides === undefined) { @@ -369,7 +368,15 @@ export function getLinkExtensionOnClick( category: config.category, }); - const result = onClick(event, getEventHelpers(pluginId, context)); + const helpers: PluginExtensionEventHelpers = { + context, + openModal: createOpenModalFunction(pluginId), + isAppOpened: () => isAppOpened(pluginId), + openAppInSideview: () => openAppInSideview(pluginId), + closeAppInSideview: () => closeAppInSideview(pluginId), + }; + + const result = onClick(event, helpers); if (isPromise(result)) { result.catch((e) => { @@ -395,3 +402,9 @@ export function getLinkExtensionPathWithTracking(pluginId: string, path: string, }) ); } + +export const openAppInSideview = (pluginId: string) => sidecarService.openApp(pluginId); + +export const closeAppInSideview = (pluginId: string) => sidecarService.closeApp(pluginId); + +export const isAppOpened = (pluginId: string) => sidecarService.isAppOpened(pluginId); diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index c7ad7a8f271..efb329af5cf 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -70,7 +70,7 @@ describe('Plugin Extension Validators', () => { title: 'Title', description: 'Description', targets: 'grafana/some-page/extension-point-a', - configure: () => {}, + configure: (_, {}) => {}, } as PluginExtensionAddedLinkConfig); }).not.toThrowError(); }); diff --git a/public/app/features/plugins/utils.test.ts b/public/app/features/plugins/utils.test.ts index 349a9870a6b..533228ac99e 100644 --- a/public/app/features/plugins/utils.test.ts +++ b/public/app/features/plugins/utils.test.ts @@ -4,7 +4,6 @@ import { HOME_NAV_ID } from 'app/core/reducers/navModel'; import { buildPluginSectionNav } from './utils'; describe('buildPluginSectionNav', () => { - const pluginNav = { main: { text: 'Plugin nav' }, node: { text: 'Plugin nav' } }; const app1: NavModelItem = { text: 'App1', id: 'plugin-page-app1', @@ -60,36 +59,36 @@ describe('buildPluginSectionNav', () => { app1.parentItem = appsSection; it('Should return return section nav', () => { - const result = buildPluginSectionNav(appsSection, pluginNav, '/a/plugin1/page1'); + const result = buildPluginSectionNav('/a/plugin1/page1', appsSection); expect(result?.main.text).toBe('apps'); }); it('Should set active page', () => { - const result = buildPluginSectionNav(appsSection, null, '/a/plugin1/page2'); + const result = buildPluginSectionNav('/a/plugin1/page2', appsSection); expect(result?.main.children![0].children![1].active).toBe(true); expect(result?.node.text).toBe('page2'); }); it('Should only set the most specific match as active (not the parents)', () => { - const result = buildPluginSectionNav(appsSection, null, '/a/plugin1/page2'); + const result = buildPluginSectionNav('/a/plugin1/page2', appsSection); expect(result?.main.children![0].children![1].active).toBe(true); expect(result?.main.children![0].active).not.toBe(true); // Parent should not be active }); it('Should set app section to active', () => { - const result = buildPluginSectionNav(appsSection, null, '/a/plugin1'); + const result = buildPluginSectionNav('/a/plugin1', appsSection); expect(result?.main.children![0].active).toBe(true); expect(result?.node.text).toBe('App1'); }); it('Should handle standalone page', () => { - const result = buildPluginSectionNav(adminSection, pluginNav, '/a/app2/config'); + const result = buildPluginSectionNav('/a/app2/config', adminSection); expect(result?.main.text).toBe('Admin'); expect(result?.node.text).toBe('Standalone page'); }); it('Should set nested active page', () => { - const result = buildPluginSectionNav(appsSection, null, '/a/plugin1/page3/page4'); + const result = buildPluginSectionNav('/a/plugin1/page3/page4', appsSection); expect(result?.main.children![0].children![2].children![0].active).toBe(true); expect(result?.node.text).toBe('page4'); }); diff --git a/public/app/features/plugins/utils.ts b/public/app/features/plugins/utils.ts index 075fc3c2542..845785ce876 100644 --- a/public/app/features/plugins/utils.ts +++ b/public/app/features/plugins/utils.ts @@ -30,11 +30,10 @@ export async function loadPlugin(pluginId: string): Promise { return result; } -export function buildPluginSectionNav( - pluginNavSection: NavModelItem, - pluginNav: NavModel | null, - currentUrl: string -): NavModel | undefined { +export function buildPluginSectionNav(currentUrl: string, pluginNavSection?: NavModelItem): NavModel | undefined { + if (!pluginNavSection) { + return undefined; + } // shallow clone as we set active flag const MAX_RECURSION_DEPTH = 10; let copiedPluginNavSection = { ...pluginNavSection }; diff --git a/public/app/routes/RoutesWrapper.tsx b/public/app/routes/RoutesWrapper.tsx new file mode 100644 index 00000000000..19409cd6663 --- /dev/null +++ b/public/app/routes/RoutesWrapper.tsx @@ -0,0 +1,150 @@ +import { css } from '@emotion/css'; +import * as H from 'history'; +import { ComponentType } from 'react'; +import { Router } from 'react-router-dom'; +import { CompatRouter } from 'react-router-dom-v5-compat'; + +import { GrafanaTheme2 } from '@grafana/data/'; +import { HistoryWrapper, locationService, LocationServiceProvider } from '@grafana/runtime'; +import { GlobalStyles, IconButton, ModalRoot, Stack, useSplitter, useStyles2 } from '@grafana/ui'; + +import { AngularRoot } from '../angular/AngularRoot'; +import { AppChrome } from '../core/components/AppChrome/AppChrome'; +import { TOP_BAR_LEVEL_HEIGHT } from '../core/components/AppChrome/types'; +import { AppNotificationList } from '../core/components/AppNotifications/AppNotificationList'; +import { ModalsContextProvider } from '../core/context/ModalsContextProvider'; +import { useSidecar } from '../core/context/SidecarContext'; +import AppRootPage from '../features/plugins/components/AppRootPage'; + +type RouterWrapperProps = { + routes?: JSX.Element | false; + bodyRenderHooks: ComponentType[]; + pageBanners: ComponentType[]; +}; +export function RouterWrapper(props: RouterWrapperProps) { + return ( + + + + + + + + + {props.pageBanners.map((Banner, index) => ( + + ))} + {props.routes} + + {props.bodyRenderHooks.map((Hook, index) => ( + + ))} + + + + + + + ); +} + +/** + * Renders both the main app tree and a secondary sidecar app tree to show 2 apps at the same time in a resizable split + * view. + * @param props + * @constructor + */ +export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) { + const { activePluginId, closeApp } = useSidecar(); + + let { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({ + direction: 'row', + initialSize: 0.6, + dragPosition: 'end', + }); + + // The style changes allow the resizing to be more flexible and not constrained by the content dimensions. In the + // future this could be a switch in the useSplitter but for now it's here until this feature is more final. + function alterStyles(props: T): T { + return { + ...props, + style: { ...props.style, overflow: 'auto', minWidth: 'unset', minHeight: 'unset' }, + }; + } + primaryProps = alterStyles(primaryProps); + secondaryProps = alterStyles(secondaryProps); + + // TODO: this should be used to calculate the height of the header but right now results in a error loop when + // navigating to explore "Cannot destructure property 'range' of 'itemState' as it is undefined." + // const headerHeight = useChromeHeaderHeight(); + const styles = useStyles2(getStyles, TOP_BAR_LEVEL_HEIGHT * 2); + const memoryLocationService = new HistoryWrapper(H.createMemoryHistory({ initialEntries: ['/'] })); + + return ( + // Why do we need these 2 wrappers here? We want for one app case to render very similar as if there was no split + // wrapper at all but the split wrapper needs to have these wrappers to attach its container props to. At the same + // time we don't want to rerender the main app when going from 2 apps render to single app render which would happen + // if we removed the wrappers. So the solution is to keep those 2 divs but make them no actually do anything in + // case we are rendering a single app. +
+
+ +
+ {/* Sidecar */} + {activePluginId && ( + <> +
+
+ + + + +
+
+ closeApp(activePluginId)} + /> +
+ +
+
+
+
+
+ + )} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2, headerHeight: number | undefined) => { + return { + secondAppWrapper: css({ + label: 'secondAppWrapper', + display: 'flex', + height: '100%', + paddingTop: headerHeight || 0, + flexGrow: 1, + flexDirection: 'column', + }), + + secondAppToolbar: css({ + label: 'secondAppToolbar', + display: 'flex', + justifyContent: 'flex-end', + }), + + // This is basically the same as grafana-app class. This means the 2 additional wrapper divs that are in between + // grafana-app div and the main app component don't actually change anything in the layout. + dummyWrapper: css({ + label: 'dummyWrapper', + display: 'flex', + height: '100vh', + flexDirection: 'column', + }), + }; +};