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 <balogh.levente.hu@gmail.com>

* 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 <balogh.levente.hu@gmail.com>
pull/92592/head
Andrej Ocenas 9 months ago committed by GitHub
parent ba9f1da28e
commit 5e2ac24890
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  3. 23
      packages/grafana-data/src/types/pluginExtensions.ts
  4. 1
      packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts
  5. 20
      packages/grafana-ui/src/components/Splitter/useSplitter.ts
  6. 6
      pkg/services/featuremgmt/registry.go
  7. 1
      pkg/services/featuremgmt/toggles_gen.csv
  8. 4
      pkg/services/featuremgmt/toggles_gen.go
  9. 12
      pkg/services/featuremgmt/toggles_gen.json
  10. 68
      public/app/AppWrapper.tsx
  11. 22
      public/app/core/context/SidecarContext.ts
  12. 74
      public/app/core/services/SidecarService.ts
  13. 4
      public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx
  14. 7
      public/app/features/plugins/components/AppRootPage.tsx
  15. 5
      public/app/features/plugins/extensions/getPluginExtensions.test.tsx
  16. 12
      public/app/features/plugins/extensions/getPluginExtensions.ts
  17. 7
      public/app/features/plugins/extensions/usePluginExtensions.tsx
  18. 6
      public/app/features/plugins/extensions/usePluginLinks.tsx
  19. 153
      public/app/features/plugins/extensions/utils.test.tsx
  20. 31
      public/app/features/plugins/extensions/utils.tsx
  21. 2
      public/app/features/plugins/extensions/validators.test.tsx
  22. 13
      public/app/features/plugins/utils.test.ts
  23. 9
      public/app/features/plugins/utils.ts
  24. 150
      public/app/routes/RoutesWrapper.tsx

@ -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 | | `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 | | `exploreLogsAggregatedMetrics` | Used in Explore Logs to query by aggregated metrics |
| `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range | | `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 ## Development feature toggles

@ -208,4 +208,5 @@ export interface FeatureToggles {
exploreLogsAggregatedMetrics?: boolean; exploreLogsAggregatedMetrics?: boolean;
exploreLogsLimitedTimeRange?: boolean; exploreLogsLimitedTimeRange?: boolean;
appPlatformAccessTokens?: boolean; appPlatformAccessTokens?: boolean;
appSidecar?: boolean;
} }

@ -75,7 +75,10 @@ export type PluginExtensionAddedComponentConfig<Props = {}> = PluginExtensionCon
component: React.ComponentType<Props>; component: React.ComponentType<Props>;
}; };
export type PluginAddedLinksConfigureFunc<Context extends object> = (context?: Readonly<Context>) => export type PluginAddedLinksConfigureFunc<Context extends object> = (
context: Readonly<Context> | undefined,
helpers: PluginExtensionHelpers
) =>
| Partial<{ | Partial<{
title: string; title: string;
description: string; description: string;
@ -142,11 +145,27 @@ export type PluginExtensionOpenModalOptions = {
height?: string | number; 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 extends object = object> = { export type PluginExtensionEventHelpers<Context extends object = object> = {
context?: Readonly<Context>; context?: Readonly<Context>;
// Opens a modal dialog and renders the provided React component inside it // Opens a modal dialog and renders the provided React component inside it
openModal: (options: PluginExtensionOpenModalOptions) => void; 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 // Extension Points & Contexts
// -------------------------------------------------------- // --------------------------------------------------------

@ -12,6 +12,7 @@ export type UsePluginExtensions<T = PluginExtension> = (
export type GetPluginExtensionsOptions = { export type GetPluginExtensionsOptions = {
extensionPointId: string; extensionPointId: string;
// Make sure this object is properly memoized and not mutated.
context?: object | Record<string | symbol, unknown>; context?: object | Record<string | symbol, unknown>;
limitPerPlugin?: number; limitPerPlugin?: number;
}; };

@ -300,6 +300,16 @@ export function useSplitter(options: UseSplitterOptions) {
const dragHandleStyle = direction === 'column' ? dragStyles.dragHandleHorizontal : dragStyles.dragHandleVertical; const dragHandleStyle = direction === 'column' ? dragStyles.dragHandleHorizontal : dragStyles.dragHandleVertical;
const id = useId(); 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 { return {
containerProps: { containerProps: {
ref: containerRef, ref: containerRef,
@ -308,18 +318,12 @@ export function useSplitter(options: UseSplitterOptions) {
primaryProps: { primaryProps: {
ref: firstPaneRef, ref: firstPaneRef,
className: styles.panel, className: styles.panel,
style: { style: primaryStyles,
[minDimProp]: 'min-content',
flexGrow: clamp(initialSize ?? 0.5, 0, 1),
},
}, },
secondaryProps: { secondaryProps: {
ref: secondPaneRef, ref: secondPaneRef,
className: styles.panel, className: styles.panel,
style: { style: secondaryStyles,
flexGrow: clamp(1 - initialSize, 0, 1),
[minDimProp]: 'min-content',
},
}, },
splitterProps: { splitterProps: {
onPointerUp, onPointerUp,

@ -1434,6 +1434,12 @@ var (
HideFromDocs: true, HideFromDocs: true,
HideFromAdminPage: true, HideFromAdminPage: true,
}, },
{
Name: "appSidecar",
Description: "Enable the app sidecar feature that allows rendering 2 apps at the same time",
Stage: FeatureStageExperimental,
Owner: grafanaExploreSquad,
},
} }
) )

@ -189,3 +189,4 @@ exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,t
exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true
exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true
appPlatformAccessTokens,experimental,@grafana/identity-access-team,false,false,false appPlatformAccessTokens,experimental,@grafana/identity-access-team,false,false,false
appSidecar,experimental,@grafana/explore-squad,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
189 exploreLogsAggregatedMetrics experimental @grafana/observability-logs false false true
190 exploreLogsLimitedTimeRange experimental @grafana/observability-logs false false true
191 appPlatformAccessTokens experimental @grafana/identity-access-team false false false
192 appSidecar experimental @grafana/explore-squad false false false

@ -766,4 +766,8 @@ const (
// FlagAppPlatformAccessTokens // FlagAppPlatformAccessTokens
// Enables the use of access tokens for the App Platform // Enables the use of access tokens for the App Platform
FlagAppPlatformAccessTokens = "appPlatformAccessTokens" FlagAppPlatformAccessTokens = "appPlatformAccessTokens"
// FlagAppSidecar
// Enable the app sidecar feature that allows rendering 2 apps at the same time
FlagAppSidecar = "appSidecar"
) )

@ -350,6 +350,18 @@
"hideFromDocs": true "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": { "metadata": {
"name": "authAPIAccessTokenAuth", "name": "authAPIAccessTokenAuth",

@ -1,32 +1,25 @@
import { Action, KBarProvider } from 'kbar'; import { Action, KBarProvider } from 'kbar';
import { Component, ComponentType } from 'react'; import { Component, ComponentType } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { Router, Redirect, Switch, RouteComponentProps } from 'react-router-dom'; import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { CompatRouter, CompatRoute } from 'react-router-dom-v5-compat'; import { CompatRoute } from 'react-router-dom-v5-compat';
import { import { config, navigationLogger, reportInteraction } from '@grafana/runtime';
config, import { ErrorBoundaryAlert, GlobalStyles, PortalContainer } from '@grafana/ui';
locationService,
LocationServiceProvider,
navigationLogger,
reportInteraction,
} from '@grafana/runtime';
import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, PortalContainer, Stack } from '@grafana/ui';
import { getAppRoutes } from 'app/routes/routes'; import { getAppRoutes } from 'app/routes/routes';
import { store } from 'app/store/store'; import { store } from 'app/store/store';
import { AngularRoot } from './angular/AngularRoot';
import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled'; import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled';
import { GrafanaApp } from './app'; 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 { GrafanaContext } from './core/context/GrafanaContext';
import { ModalsContextProvider } from './core/context/ModalsContextProvider'; import { SidecarContext } from './core/context/SidecarContext';
import { GrafanaRoute } from './core/navigation/GrafanaRoute'; import { GrafanaRoute } from './core/navigation/GrafanaRoute';
import { RouteDescriptor } from './core/navigation/types'; import { RouteDescriptor } from './core/navigation/types';
import { sidecarService } from './core/services/SidecarService';
import { contextSrv } from './core/services/context_srv'; import { contextSrv } from './core/services/context_srv';
import { ThemeProvider } from './core/utils/ConfigProvider'; import { ThemeProvider } from './core/utils/ConfigProvider';
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning'; import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
import { ExperimentalSplitPaneRouterWrapper, RouterWrapper } from './routes/RoutesWrapper';
interface AppWrapperProps { interface AppWrapperProps {
app: GrafanaApp; app: GrafanaApp;
@ -100,6 +93,12 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
}); });
}; };
const routerWrapperProps = {
routes: ready && this.renderRoutes(),
pageBanners,
bodyRenderHooks,
};
return ( return (
<Provider store={store}> <Provider store={store}>
<ErrorBoundaryAlert style="page"> <ErrorBoundaryAlert style="page">
@ -109,33 +108,18 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
actions={[]} actions={[]}
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }} options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
> >
<Router history={locationService.getHistory()}> <GlobalStyles />
<LocationServiceProvider service={locationService}> <SidecarContext.Provider value={sidecarService}>
<CompatRouter> <div className="grafana-app">
<ModalsContextProvider> {config.featureToggles.appSidecar ? (
<GlobalStyles /> <ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
<div className="grafana-app"> ) : (
<AppChrome> <RouterWrapper {...routerWrapperProps} />
<AngularRoot /> )}
<AppNotificationList /> <LiveConnectionWarning />
<Stack gap={0} grow={1} direction="column"> <PortalContainer />
{pageBanners.map((Banner, index) => ( </div>
<Banner key={index.toString()} /> </SidecarContext.Provider>
))}
{ready && this.renderRoutes()}
</Stack>
{bodyRenderHooks.map((Hook, index) => (
<Hook key={index.toString()} />
))}
</AppChrome>
</div>
<LiveConnectionWarning />
<ModalRoot />
<PortalContainer />
</ModalsContextProvider>
</CompatRouter>
</LocationServiceProvider>
</Router>
</KBarProvider> </KBarProvider>
</ThemeProvider> </ThemeProvider>
</GrafanaContext.Provider> </GrafanaContext.Provider>

@ -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>(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),
};
}

@ -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<string | undefined>;
constructor() {
this._activePluginId = new BehaviorSubject<string | undefined>(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';
}

@ -43,7 +43,7 @@ describe('getExploreExtensionConfigs', () => {
const extensions = getExploreExtensionConfigs(); const extensions = getExploreExtensionConfigs();
const [extension] = extensions; const [extension] = extensions;
expect(extension?.configure?.()).toBeUndefined(); expect(extension?.configure?.(undefined, { isAppOpened: () => false })).toBeUndefined();
}); });
it('should return empty object if sufficient permissions', () => { it('should return empty object if sufficient permissions', () => {
@ -52,7 +52,7 @@ describe('getExploreExtensionConfigs', () => {
const extensions = getExploreExtensionConfigs(); const extensions = getExploreExtensionConfigs();
const [extension] = extensions; const [extension] = extensions;
expect(extension?.configure?.()).toEqual({}); expect(extension?.configure?.(undefined, { isAppOpened: () => false })).toEqual({});
}); });
}); });
}); });

@ -33,8 +33,9 @@ import { buildPluginPageContext, PluginPageContext } from './PluginPageContext';
interface Props { interface Props {
// The ID of the plugin we would like to load and display // The ID of the plugin we would like to load and display
pluginId: string; pluginId: string;
// The root navModelItem for the plugin (root = lives directly under 'home') // The root navModelItem for the plugin (root = lives directly under 'home'). In case app does not need a nva model,
pluginNavSection: NavModelItem; // for example it's in some way embedded or shown in a sideview this can be undefined.
pluginNavSection?: NavModelItem;
} }
interface State { interface State {
@ -53,7 +54,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
const [state, dispatch] = useReducer(stateSlice.reducer, initialState); const [state, dispatch] = useReducer(stateSlice.reducer, initialState);
const currentUrl = config.appSubUrl + location.pathname + location.search; const currentUrl = config.appSubUrl + location.pathname + location.search;
const { plugin, loading, loadingError, pluginNav } = state; 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 queryParams = useMemo(() => locationSearchToObject(location.search), [location.search]);
const context = useMemo(() => buildPluginPageContext(navModel), [navModel]); const context = useMemo(() => buildPluginPageContext(navModel), [navModel]);
const grafanaContext = useGrafana(); const grafanaContext = useGrafana();

@ -174,7 +174,10 @@ describe('getPluginExtensions()', () => {
getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 });
expect(link2.configure).toHaveBeenCalledTimes(1); 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 () => { test('should be possible to update the basic properties with the configure() function', async () => {

@ -88,11 +88,7 @@ export const getPluginExtensions: GetExtensions = ({
const path = overrides?.path || addedLink.path; const path = overrides?.path || addedLink.path;
const extension: PluginExtensionLink = { const extension: PluginExtensionLink = {
id: generateExtensionId(pluginId, { id: generateExtensionId(pluginId, extensionPointId, addedLink.title),
...addedLink,
extensionPointId,
type: PluginExtensionTypes.link,
}),
type: PluginExtensionTypes.link, type: PluginExtensionTypes.link,
pluginId: pluginId, pluginId: pluginId,
onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext), onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext),
@ -125,11 +121,7 @@ export const getPluginExtensions: GetExtensions = ({
extensionsByPlugin[addedComponent.pluginId] = 0; extensionsByPlugin[addedComponent.pluginId] = 0;
} }
const extension: PluginExtensionComponent = { const extension: PluginExtensionComponent = {
id: generateExtensionId(addedComponent.pluginId, { id: generateExtensionId(addedComponent.pluginId, extensionPointId, addedComponent.title),
...addedComponent,
extensionPointId,
type: PluginExtensionTypes.component,
}),
type: PluginExtensionTypes.component, type: PluginExtensionTypes.component,
pluginId: addedComponent.pluginId, pluginId: addedComponent.pluginId,
title: addedComponent.title, title: addedComponent.title,

@ -3,6 +3,7 @@ import { useObservable } from 'react-use';
import { PluginExtension } from '@grafana/data'; import { PluginExtension } from '@grafana/data';
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime'; import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime';
import { useSidecar } from 'app/core/context/SidecarContext';
import { getPluginExtensions } from './getPluginExtensions'; import { getPluginExtensions } from './getPluginExtensions';
import { PluginExtensionRegistries } from './registry/types'; import { PluginExtensionRegistries } from './registry/types';
@ -14,6 +15,7 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> { return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> {
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry); const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
const addedLinksRegistry = useObservable(observableAddedLinksRegistry); const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
const { activePluginId } = useSidecar();
const { extensions } = useMemo(() => { const { extensions } = useMemo(() => {
if (!addedLinksRegistry && !addedComponentsRegistry) { if (!addedLinksRegistry && !addedComponentsRegistry) {
@ -27,12 +29,17 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
addedComponentsRegistry, addedComponentsRegistry,
addedLinksRegistry, 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, addedLinksRegistry,
addedComponentsRegistry, addedComponentsRegistry,
options.extensionPointId, options.extensionPointId,
options.context, options.context,
options.limitPerPlugin, options.limitPerPlugin,
activePluginId,
]); ]);
return { extensions, isLoading: false }; return { extensions, isLoading: false };

@ -60,11 +60,7 @@ export function createUsePluginLinks(registry: AddedLinksRegistry) {
const path = overrides?.path || addedLink.path; const path = overrides?.path || addedLink.path;
const extension: PluginExtensionLink = { const extension: PluginExtensionLink = {
id: generateExtensionId(pluginId, { id: generateExtensionId(pluginId, extensionPointId, addedLink.title),
...addedLink,
extensionPointId,
type: PluginExtensionTypes.link,
}),
type: PluginExtensionTypes.link, type: PluginExtensionTypes.link,
pluginId: pluginId, pluginId: pluginId,
onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext), onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext),

@ -5,7 +5,13 @@ import { dateTime, usePluginContext } from '@grafana/data';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/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.mock('app/features/plugins/pluginSettings', () => ({
...jest.requireActual('app/features/plugins/pluginSettings'), ...jest.requireActual('app/features/plugins/pluginSettings'),
@ -306,111 +312,100 @@ describe('Plugin Extensions / Utils', () => {
}); });
}); });
describe('getEventHelpers', () => { describe('createOpenModalFunction()', () => {
describe('openModal', () => { let renderModalSubscription: Unsubscribable | undefined;
let renderModalSubscription: Unsubscribable | undefined;
beforeAll(() => { beforeAll(() => {
renderModalSubscription = appEvents.subscribe(ShowModalReactEvent, (event) => { renderModalSubscription = appEvents.subscribe(ShowModalReactEvent, (event) => {
const { payload } = event; const { payload } = event;
const Modal = payload.component; const Modal = payload.component;
render(<Modal />); render(<Modal />);
});
});
afterAll(() => {
renderModalSubscription?.unsubscribe();
}); });
});
it('should open modal with provided title and body', async () => { afterAll(() => {
const pluginId = 'grafana-worldmap-panel'; renderModalSubscription?.unsubscribe();
const { openModal } = getEventHelpers(pluginId); });
openModal({ it('should open modal with provided title and body', async () => {
title: 'Title in modal', const pluginId = 'grafana-worldmap-panel';
body: () => <div>Text in body</div>, const openModal = createOpenModalFunction(pluginId);
});
expect(await screen.findByRole('dialog')).toBeVisible(); openModal({
expect(screen.getByRole('heading')).toHaveTextContent('Title in modal'); title: 'Title in modal',
expect(screen.getByText('Text in body')).toBeVisible(); body: () => <div>Text in body</div>,
}); });
it('should open modal with default width if not specified', async () => { expect(await screen.findByRole('dialog')).toBeVisible();
const pluginId = 'grafana-worldmap-panel'; expect(screen.getByRole('heading')).toHaveTextContent('Title in modal');
const { openModal } = getEventHelpers(pluginId); expect(screen.getByText('Text in body')).toBeVisible();
});
openModal({
title: 'Title in modal',
body: () => <div>Text in body</div>,
});
const modal = await screen.findByRole('dialog'); it('should open modal with default width if not specified', async () => {
const style = window.getComputedStyle(modal); const pluginId = 'grafana-worldmap-panel';
const openModal = createOpenModalFunction(pluginId);
expect(style.width).toBe('750px'); openModal({
expect(style.height).toBe(''); title: 'Title in modal',
body: () => <div>Text in body</div>,
}); });
it('should open modal with specified width', async () => { const modal = await screen.findByRole('dialog');
const pluginId = 'grafana-worldmap-panel'; const style = window.getComputedStyle(modal);
const { openModal } = getEventHelpers(pluginId);
openModal({ expect(style.width).toBe('750px');
title: 'Title in modal', expect(style.height).toBe('');
body: () => <div>Text in body</div>, });
width: '70%',
});
const modal = await screen.findByRole('dialog'); it('should open modal with specified width', async () => {
const style = window.getComputedStyle(modal); const pluginId = 'grafana-worldmap-panel';
const openModal = createOpenModalFunction(pluginId);
expect(style.width).toBe('70%'); openModal({
title: 'Title in modal',
body: () => <div>Text in body</div>,
width: '70%',
}); });
it('should open modal with specified height', async () => { const modal = await screen.findByRole('dialog');
const pluginId = 'grafana-worldmap-panel'; const style = window.getComputedStyle(modal);
const { openModal } = getEventHelpers(pluginId);
openModal({ expect(style.width).toBe('70%');
title: 'Title in modal', });
body: () => <div>Text in body</div>,
height: 600,
});
const modal = await screen.findByRole('dialog'); it('should open modal with specified height', async () => {
const style = window.getComputedStyle(modal); const pluginId = 'grafana-worldmap-panel';
const openModal = createOpenModalFunction(pluginId);
expect(style.height).toBe('600px'); openModal({
title: 'Title in modal',
body: () => <div>Text in body</div>,
height: 600,
}); });
it('should open modal with the plugin context being available', async () => { const modal = await screen.findByRole('dialog');
const pluginId = 'grafana-worldmap-panel'; const style = window.getComputedStyle(modal);
const { openModal } = getEventHelpers(pluginId);
expect(style.height).toBe('600px');
});
const ModalContent = () => { it('should open modal with the plugin context being available', async () => {
const context = usePluginContext(); const pluginId = 'grafana-worldmap-panel';
const openModal = createOpenModalFunction(pluginId);
return <div>Version: {context.meta.info.version}</div>; const ModalContent = () => {
}; const context = usePluginContext();
openModal({ return <div>Version: {context.meta.info.version}</div>;
title: 'Title in modal', };
body: ModalContent,
});
const modal = await screen.findByRole('dialog'); openModal({
expect(modal).toHaveTextContent('Version: 1.0.0'); title: 'Title in modal',
body: ModalContent,
}); });
});
describe('context', () => { const modal = await screen.findByRole('dialog');
it('should return same object as passed to getEventHelpers', () => { expect(modal).toHaveTextContent('Version: 1.0.0');
const pluginId = 'grafana-worldmap-panel';
const source = {};
const { context } = getEventHelpers(pluginId, source);
expect(context).toBe(source);
});
}); });
}); });

@ -20,6 +20,8 @@ import {
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { Modal } from '@grafana/ui'; import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events'; 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 { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { ShowModalReactEvent } from 'app/types/events'; 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 createOpenModalFunction(pluginId: string): PluginExtensionEventHelpers['openModal'] {
export function getEventHelpers(pluginId: string, context?: Readonly<object>): PluginExtensionEventHelpers { return async (options) => {
const openModal: PluginExtensionEventHelpers['openModal'] = async (options) => {
const { title, body, width, height } = options; const { title, body, width, height } = options;
appEvents.publish( appEvents.publish(
@ -59,8 +60,6 @@ export function getEventHelpers(pluginId: string, context?: Readonly<object>): P
}) })
); );
}; };
return { openModal, context };
} }
type ModalWrapperProps = { type ModalWrapperProps = {
@ -161,8 +160,8 @@ export function deepFreeze(value?: object | Record<string | symbol, unknown> | u
return Object.freeze(clonedValue); return Object.freeze(clonedValue);
} }
export function generateExtensionId(pluginId: string, extensionConfig: PluginExtensionConfig): string { export function generateExtensionId(pluginId: string, extensionPointId: string, title: string): string {
const str = `${pluginId}${extensionConfig.extensionPointId}${extensionConfig.title}`; const str = `${pluginId}${extensionPointId}${title}`;
return Array.from(str) return Array.from(str)
.reduce((s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0) .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) { export function getLinkExtensionOverrides(pluginId: string, config: AddedLinkRegistryItem, context?: object) {
try { try {
const overrides = config.configure?.(context); const overrides = config.configure?.(context, { isAppOpened: () => isAppOpened(pluginId) });
// Hiding the extension // Hiding the extension
if (overrides === undefined) { if (overrides === undefined) {
@ -369,7 +368,15 @@ export function getLinkExtensionOnClick(
category: config.category, 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)) { if (isPromise(result)) {
result.catch((e) => { 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);

@ -70,7 +70,7 @@ describe('Plugin Extension Validators', () => {
title: 'Title', title: 'Title',
description: 'Description', description: 'Description',
targets: 'grafana/some-page/extension-point-a', targets: 'grafana/some-page/extension-point-a',
configure: () => {}, configure: (_, {}) => {},
} as PluginExtensionAddedLinkConfig); } as PluginExtensionAddedLinkConfig);
}).not.toThrowError(); }).not.toThrowError();
}); });

@ -4,7 +4,6 @@ import { HOME_NAV_ID } from 'app/core/reducers/navModel';
import { buildPluginSectionNav } from './utils'; import { buildPluginSectionNav } from './utils';
describe('buildPluginSectionNav', () => { describe('buildPluginSectionNav', () => {
const pluginNav = { main: { text: 'Plugin nav' }, node: { text: 'Plugin nav' } };
const app1: NavModelItem = { const app1: NavModelItem = {
text: 'App1', text: 'App1',
id: 'plugin-page-app1', id: 'plugin-page-app1',
@ -60,36 +59,36 @@ describe('buildPluginSectionNav', () => {
app1.parentItem = appsSection; app1.parentItem = appsSection;
it('Should return return section nav', () => { 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'); expect(result?.main.text).toBe('apps');
}); });
it('Should set active page', () => { 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?.main.children![0].children![1].active).toBe(true);
expect(result?.node.text).toBe('page2'); expect(result?.node.text).toBe('page2');
}); });
it('Should only set the most specific match as active (not the parents)', () => { 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].children![1].active).toBe(true);
expect(result?.main.children![0].active).not.toBe(true); // Parent should not be active expect(result?.main.children![0].active).not.toBe(true); // Parent should not be active
}); });
it('Should set app section to 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?.main.children![0].active).toBe(true);
expect(result?.node.text).toBe('App1'); expect(result?.node.text).toBe('App1');
}); });
it('Should handle standalone page', () => { 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?.main.text).toBe('Admin');
expect(result?.node.text).toBe('Standalone page'); expect(result?.node.text).toBe('Standalone page');
}); });
it('Should set nested active 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?.main.children![0].children![2].children![0].active).toBe(true);
expect(result?.node.text).toBe('page4'); expect(result?.node.text).toBe('page4');
}); });

@ -30,11 +30,10 @@ export async function loadPlugin(pluginId: string): Promise<GrafanaPlugin> {
return result; return result;
} }
export function buildPluginSectionNav( export function buildPluginSectionNav(currentUrl: string, pluginNavSection?: NavModelItem): NavModel | undefined {
pluginNavSection: NavModelItem, if (!pluginNavSection) {
pluginNav: NavModel | null, return undefined;
currentUrl: string }
): NavModel | undefined {
// shallow clone as we set active flag // shallow clone as we set active flag
const MAX_RECURSION_DEPTH = 10; const MAX_RECURSION_DEPTH = 10;
let copiedPluginNavSection = { ...pluginNavSection }; let copiedPluginNavSection = { ...pluginNavSection };

@ -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 (
<Router history={locationService.getHistory()}>
<LocationServiceProvider service={locationService}>
<CompatRouter>
<ModalsContextProvider>
<AppChrome>
<AngularRoot />
<AppNotificationList />
<Stack gap={0} grow={1} direction="column">
{props.pageBanners.map((Banner, index) => (
<Banner key={index.toString()} />
))}
{props.routes}
</Stack>
{props.bodyRenderHooks.map((Hook, index) => (
<Hook key={index.toString()} />
))}
</AppChrome>
<ModalRoot />
</ModalsContextProvider>
</CompatRouter>
</LocationServiceProvider>
</Router>
);
}
/**
* 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<T extends { style: React.CSSProperties }>(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.
<div {...(activePluginId ? containerProps : { className: styles.dummyWrapper })}>
<div {...(activePluginId ? primaryProps : { className: styles.dummyWrapper })}>
<RouterWrapper {...props} />
</div>
{/* Sidecar */}
{activePluginId && (
<>
<div {...splitterProps} />
<div {...secondaryProps}>
<Router history={memoryLocationService.getHistory()}>
<LocationServiceProvider service={memoryLocationService}>
<CompatRouter>
<GlobalStyles />
<div className={styles.secondAppWrapper}>
<div className={styles.secondAppToolbar}>
<IconButton
size={'lg'}
style={{ margin: '8px' }}
name={'times'}
aria-label={'close'}
onClick={() => closeApp(activePluginId)}
/>
</div>
<AppRootPage pluginId={activePluginId} />
</div>
</CompatRouter>
</LocationServiceProvider>
</Router>
</div>
</>
)}
</div>
);
}
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',
}),
};
};
Loading…
Cancel
Save