mirror of
https://github.com/grafana/grafana.git
synced 2026-09-05 04:40:13 -05:00
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>
This commit is contained in:
co-authored by
Levente Balogh
parent
ba9f1da28e
commit
5e2ac24890
@@ -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
|
||||
|
||||
|
@@ -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",
|
||||
@@ -2859,4 +2871,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+25
-41
@@ -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 { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
|
||||
import { 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 { 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();
|
||||
});
|
||||
|
||||
it('should open modal with provided title and body', 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();
|
||||
});
|
||||
|
||||
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>,
|
||||
});
|
||||
|
||||
const modal = await screen.findByRole('dialog');
|
||||
const style = window.getComputedStyle(modal);
|
||||
|
||||
expect(style.width).toBe('750px');
|
||||
expect(style.height).toBe('');
|
||||
});
|
||||
|
||||
it('should open modal with specified width', async () => {
|
||||
const pluginId = 'grafana-worldmap-panel';
|
||||
const { openModal } = getEventHelpers(pluginId);
|
||||
|
||||
openModal({
|
||||
title: 'Title in modal',
|
||||
body: () => <div>Text in body</div>,
|
||||
width: '70%',
|
||||
});
|
||||
|
||||
const modal = await screen.findByRole('dialog');
|
||||
const style = window.getComputedStyle(modal);
|
||||
|
||||
expect(style.width).toBe('70%');
|
||||
});
|
||||
|
||||
it('should open modal with specified height', async () => {
|
||||
const pluginId = 'grafana-worldmap-panel';
|
||||
const { openModal } = getEventHelpers(pluginId);
|
||||
|
||||
openModal({
|
||||
title: 'Title in modal',
|
||||
body: () => <div>Text in body</div>,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
const modal = await screen.findByRole('dialog');
|
||||
const style = window.getComputedStyle(modal);
|
||||
|
||||
expect(style.height).toBe('600px');
|
||||
});
|
||||
|
||||
it('should open modal with the plugin context being available', async () => {
|
||||
const pluginId = 'grafana-worldmap-panel';
|
||||
const { openModal } = getEventHelpers(pluginId);
|
||||
|
||||
const ModalContent = () => {
|
||||
const context = usePluginContext();
|
||||
|
||||
return <div>Version: {context.meta.info.version}</div>;
|
||||
};
|
||||
|
||||
openModal({
|
||||
title: 'Title in modal',
|
||||
body: ModalContent,
|
||||
});
|
||||
|
||||
const modal = await screen.findByRole('dialog');
|
||||
expect(modal).toHaveTextContent('Version: 1.0.0');
|
||||
beforeAll(() => {
|
||||
renderModalSubscription = appEvents.subscribe(ShowModalReactEvent, (event) => {
|
||||
const { payload } = event;
|
||||
const Modal = payload.component;
|
||||
render(<Modal />);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
afterAll(() => {
|
||||
renderModalSubscription?.unsubscribe();
|
||||
});
|
||||
|
||||
it('should open modal with provided title and body', async () => {
|
||||
const pluginId = 'grafana-worldmap-panel';
|
||||
const openModal = createOpenModalFunction(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();
|
||||
});
|
||||
|
||||
it('should open modal with default width if not specified', async () => {
|
||||
const pluginId = 'grafana-worldmap-panel';
|
||||
const openModal = createOpenModalFunction(pluginId);
|
||||
|
||||
openModal({
|
||||
title: 'Title in modal',
|
||||
body: () => <div>Text in body</div>,
|
||||
});
|
||||
|
||||
const modal = await screen.findByRole('dialog');
|
||||
const style = window.getComputedStyle(modal);
|
||||
|
||||
expect(style.width).toBe('750px');
|
||||
expect(style.height).toBe('');
|
||||
});
|
||||
|
||||
it('should open modal with specified width', async () => {
|
||||
const pluginId = 'grafana-worldmap-panel';
|
||||
const openModal = createOpenModalFunction(pluginId);
|
||||
|
||||
openModal({
|
||||
title: 'Title in modal',
|
||||
body: () => <div>Text in body</div>,
|
||||
width: '70%',
|
||||
});
|
||||
|
||||
const modal = await screen.findByRole('dialog');
|
||||
const style = window.getComputedStyle(modal);
|
||||
|
||||
expect(style.width).toBe('70%');
|
||||
});
|
||||
|
||||
it('should open modal with specified height', async () => {
|
||||
const pluginId = 'grafana-worldmap-panel';
|
||||
const openModal = createOpenModalFunction(pluginId);
|
||||
|
||||
openModal({
|
||||
title: 'Title in modal',
|
||||
body: () => <div>Text in body</div>,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
const modal = await screen.findByRole('dialog');
|
||||
const style = window.getComputedStyle(modal);
|
||||
|
||||
expect(style.height).toBe('600px');
|
||||
});
|
||||
|
||||
it('should open modal with the plugin context being available', async () => {
|
||||
const pluginId = 'grafana-worldmap-panel';
|
||||
const openModal = createOpenModalFunction(pluginId);
|
||||
|
||||
const ModalContent = () => {
|
||||
const context = usePluginContext();
|
||||
|
||||
return <div>Version: {context.meta.info.version}</div>;
|
||||
};
|
||||
|
||||
openModal({
|
||||
title: 'Title in modal',
|
||||
body: ModalContent,
|
||||
});
|
||||
|
||||
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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user