Sidebar: Create a sidebar that can render an extension (#102626)

* Extension Sidebar: Add missing `web-section` icon

* Extension Sidebar: Add core extension sidebar components

* Extension Sidebar: Integrate extension sidebar into Grafana

* Extension Sidebar: Change extension point to alpha

* Extension Sidebar: Fix saved state of docked extensions

* Extension Sidebar: Delete from local storage if undocked

* Extension Sidebar: Move main scrollbar from body to pane

* Extension Sidebar: Expose `ExtensionInfo`

* Extension Sidebar: Move `useComponents` into ExtensionSidebar

* Extension Sidebar: Store selection in `localStorage`

* Extension Sidebar: Simplify return of extension point meta

* Extension Sidebar: Ensure `body` is scrollable when sidebar is closed

* Extension Sidebar: Add missing `GlobalStyles` change

* Extension Sidebar: Don't render `ExtensionSidebar` unless it should be open

* Extension Sidebar: Better toggle handling

* Extension Sidebar: Fix wrong header height

* Extension Sidebar: Change `getExtensionPointPluginMeta` to use `addedComponents` and add documentation

* Extension Sidebar: Add tests for `getExtensionPointPluginMeta`

* Extension Sidebar: Add tests for `ExtensionSidebarProvider`

* Extension Sidebar: Fix tests `ExtensionSidebarProvider`

* Extension Sidebar: Add tests `ExtensionToolbarItem`

* Extension Sidebar: Add `extensionSidebar` feature toggle

* Extension Sidebar: Put implementation behind `extensionSidebar` feature toggle

* update feature toggles

* fix lint

* Extension Sidebar: Only toggle if clicking the same button

* Extension Sidebar: Hide sidebar if chromeless

* Update feature toggles doc

* Sidebar: Add `isEnabled` to ExtensionSidebarProvider

* Extension Sidebar: Use conditional CSS classes

* Extension Sidebar: Move header height to GrafanaContext

* Sidebar: Adapt to feature toggle change

* Sidebar: Remove unused import

* Sidebar: Keep featuretoggles in ExtensionSidebar tests

* ProviderConfig: Keep `config` import in tests

* FeatureToggles: adapt to docs review

* fix typo
This commit is contained in:
Sven Grossmann
2025-04-03 12:16:35 +02:00
committed by GitHub
parent b97b1cc730
commit f277902682
23 changed files with 1033 additions and 53 deletions

View File

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
import { type Unsubscribable } from 'rxjs';
import { dateTime, usePluginContext, PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { config, AppPluginConfig } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/events';
@ -18,6 +18,7 @@ import {
getAppPluginConfigs,
getAppPluginIdFromExposedComponentId,
getAppPluginDependencies,
getExtensionPointPluginMeta,
} from './utils';
jest.mock('app/features/plugins/pluginSettings', () => ({
@ -982,4 +983,121 @@ describe('Plugin Extensions / Utils', () => {
expect(appPluginIds).toEqual([]);
});
});
describe('getExtensionPointPluginMeta()', () => {
const originalApps = config.apps;
const mockExtensionPointId = 'test-extension-point';
const mockApp1: AppPluginConfig = {
id: 'app1',
path: 'app1',
version: '1.0.0',
preload: false,
angular: { detected: false, hideDeprecation: false },
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedComponents: [
{ title: 'Component 1', targets: [mockExtensionPointId] },
{ title: 'Component 2', targets: ['other-point'] },
],
addedLinks: [
{ title: 'Link 1', targets: [mockExtensionPointId] },
{ title: 'Link 2', targets: ['other-point'] },
],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
const mockApp2: AppPluginConfig = {
id: 'app2',
path: 'app2',
version: '1.0.0',
preload: false,
angular: { detected: false, hideDeprecation: false },
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedComponents: [{ title: 'Component 3', targets: [mockExtensionPointId] }],
addedLinks: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
beforeEach(() => {
config.apps = {};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return empty map when no plugins have extensions for the point', () => {
config.apps = {
app1: { ...mockApp1, extensions: { ...mockApp1.extensions, addedComponents: [], addedLinks: [] } },
app2: { ...mockApp2, extensions: { ...mockApp2.extensions, addedComponents: [], addedLinks: [] } },
};
const result = getExtensionPointPluginMeta(mockExtensionPointId);
expect(result.size).toBe(0);
});
it('should return map with plugins that have components for the extension point', () => {
config.apps = {
app1: mockApp1,
app2: mockApp2,
};
const result = getExtensionPointPluginMeta(mockExtensionPointId);
expect(result.size).toBe(2);
expect(result.get('app1')).toEqual({
addedComponents: [{ title: 'Component 1', targets: [mockExtensionPointId] }],
addedLinks: [{ title: 'Link 1', targets: [mockExtensionPointId] }],
});
expect(result.get('app2')).toEqual({
addedComponents: [{ title: 'Component 3', targets: [mockExtensionPointId] }],
addedLinks: [],
});
});
it('should filter out plugins that do not have any extensions for the point', () => {
config.apps = {
app1: mockApp1,
app2: { ...mockApp2, extensions: { ...mockApp2.extensions, addedComponents: [], addedLinks: [] } },
app3: {
...mockApp1,
id: 'app3',
extensions: {
...mockApp1.extensions,
addedComponents: [{ title: 'Component 4', targets: ['other-point'] }],
addedLinks: [{ title: 'Link 3', targets: ['other-point'] }],
},
},
};
const result = getExtensionPointPluginMeta(mockExtensionPointId);
expect(result.size).toBe(1);
expect(result.get('app1')).toEqual({
addedComponents: [{ title: 'Component 1', targets: [mockExtensionPointId] }],
addedLinks: [{ title: 'Link 1', targets: [mockExtensionPointId] }],
});
});
});
});

View File

@ -17,6 +17,7 @@ import {
PluginExtensionAddedLinkConfig,
urlUtil,
PluginExtensionPoints,
ExtensionInfo,
} from '@grafana/data';
import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
@ -446,6 +447,46 @@ export const getExtensionPointPluginDependencies = (extensionPointId: string): s
}, []);
};
export type ExtensionPointPluginMeta = Map<
string,
{
readonly addedComponents: ExtensionInfo[];
readonly addedLinks: ExtensionInfo[];
}
>;
/**
* Returns a map of plugin ids and their addedComponents and addedLinks to the extension point.
* @param extensionPointId - The id of the extension point.
* @returns A map of plugin ids and their addedComponents and addedLinks to the extension point.
*/
export const getExtensionPointPluginMeta = (extensionPointId: string): ExtensionPointPluginMeta => {
return new Map(
getExtensionPointPluginDependencies(extensionPointId)
.map((pluginId) => {
const app = config.apps[pluginId];
// if the plugin does not exist or does not expose any components or links to the extension point, return undefined
if (
!app ||
(!app.extensions.addedComponents.some((component) => component.targets.includes(extensionPointId)) &&
!app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)))
) {
return undefined;
}
return [
pluginId,
{
addedComponents: app.extensions.addedComponents.filter((component) =>
component.targets.includes(extensionPointId)
),
addedLinks: app.extensions.addedLinks.filter((link) => link.targets.includes(extensionPointId)),
},
] as const;
})
.filter((c): c is NonNullable<typeof c> => c !== undefined)
);
};
// Returns a list of app plugin ids that are necessary to be loaded to use the exposed component.
// (It is first the plugin that exposes the component, and then the ones that it depends on.)
export const getExposedComponentPluginDependencies = (exposedComponentId: string) => {