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 8 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 |
| `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

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

@ -75,7 +75,10 @@ export type PluginExtensionAddedComponentConfig<Props = {}> = PluginExtensionCon
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<{
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 extends object = object> = {
context?: Readonly<Context>;
// 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
// --------------------------------------------------------

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

@ -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,

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

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

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
// 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"
)

@ -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",

@ -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<AppWrapperProps, AppWrapperState> {
});
};
const routerWrapperProps = {
routes: ready && this.renderRoutes(),
pageBanners,
bodyRenderHooks,
};
return (
<Provider store={store}>
<ErrorBoundaryAlert style="page">
@ -109,33 +108,18 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
actions={[]}
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
>
<Router history={locationService.getHistory()}>
<LocationServiceProvider service={locationService}>
<CompatRouter>
<ModalsContextProvider>
<GlobalStyles />
<div className="grafana-app">
<AppChrome>
<AngularRoot />
<AppNotificationList />
<Stack gap={0} grow={1} direction="column">
{pageBanners.map((Banner, index) => (
<Banner key={index.toString()} />
))}
{ready && this.renderRoutes()}
</Stack>
{bodyRenderHooks.map((Hook, index) => (
<Hook key={index.toString()} />
))}
</AppChrome>
</div>
<LiveConnectionWarning />
<ModalRoot />
<PortalContainer />
</ModalsContextProvider>
</CompatRouter>
</LocationServiceProvider>
</Router>
<GlobalStyles />
<SidecarContext.Provider value={sidecarService}>
<div className="grafana-app">
{config.featureToggles.appSidecar ? (
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
) : (
<RouterWrapper {...routerWrapperProps} />
)}
<LiveConnectionWarning />
<PortalContainer />
</div>
</SidecarContext.Provider>
</KBarProvider>
</ThemeProvider>
</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 [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({});
});
});
});

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

@ -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 () => {

@ -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,

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

@ -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),

@ -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(<Modal />);
});
});
afterAll(() => {
renderModalSubscription?.unsubscribe();
beforeAll(() => {
renderModalSubscription = appEvents.subscribe(ShowModalReactEvent, (event) => {
const { payload } = event;
const Modal = payload.component;
render(<Modal />);
});
});
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: () => <div>Text in body</div>,
});
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: () => <div>Text in body</div>,
});
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: () => <div>Text in body</div>,
});
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: () => <div>Text in body</div>,
});
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: () => <div>Text in body</div>,
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: () => <div>Text in body</div>,
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: () => <div>Text in body</div>,
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: () => <div>Text in body</div>,
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 <div>Version: {context.meta.info.version}</div>;
};
const ModalContent = () => {
const context = usePluginContext();
openModal({
title: 'Title in modal',
body: ModalContent,
});
return <div>Version: {context.meta.info.version}</div>;
};
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');
});
});

@ -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<object>): 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<object>): P
})
);
};
return { openModal, context };
}
type ModalWrapperProps = {
@ -161,8 +160,8 @@ export function deepFreeze(value?: object | Record<string | symbol, unknown> | 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);

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

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

@ -30,11 +30,10 @@ export async function loadPlugin(pluginId: string): Promise<GrafanaPlugin> {
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 };

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