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
pull/103255/head
Sven Grossmann 4 months ago committed by GitHub
parent b97b1cc730
commit f277902682
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 1
      packages/grafana-data/src/index.ts
  3. 8
      packages/grafana-data/src/types/featureToggles.gen.ts
  4. 1
      packages/grafana-data/src/types/icon.ts
  5. 5
      packages/grafana-ui/src/themes/GlobalStyles/GlobalStyles.tsx
  6. 14
      packages/grafana-ui/src/themes/GlobalStyles/elements.ts
  7. 11
      pkg/services/featuremgmt/registry.go
  8. 1
      pkg/services/featuremgmt/toggles_gen.csv
  9. 8
      pkg/services/featuremgmt/toggles_gen.go
  10. 61
      pkg/services/featuremgmt/toggles_gen.json
  11. 29
      public/app/AppWrapper.tsx
  12. 37
      public/app/core/components/AppChrome/AppChrome.tsx
  13. 64
      public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx
  14. 241
      public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx
  15. 132
      public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx
  16. 209
      public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx
  17. 74
      public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx
  18. 14
      public/app/core/components/AppChrome/ExtensionSidebar/GlobalStylesWrapper.tsx
  19. 2
      public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx
  20. 7
      public/app/core/context/GrafanaContext.ts
  21. 1
      public/app/features/auth-config/ProviderConfigForm.test.tsx
  22. 120
      public/app/features/plugins/extensions/utils.test.tsx
  23. 41
      public/app/features/plugins/extensions/utils.tsx

@ -222,9 +222,10 @@ Experimental features might be changed or removed without prior notice.
| `grafanaAdvisor` | Enables Advisor app | | `grafanaAdvisor` | Enables Advisor app |
| `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing | | `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing |
| `newLogsPanel` | Enables the new logs panel in Explore | | `newLogsPanel` | Enables the new logs panel in Explore |
| `pluginsCDNSyncLoader` | Load plugins from CDN synchronously | | `pluginsCDNSyncLoader` | Loads plugins from CDN synchronously |
| `assetSriChecks` | Enables SRI checks for Grafana JavaScript assets | | `assetSriChecks` | Enables SRI checks for Grafana JavaScript assets |
| `localeFormatPreference` | Specify the locale so we can show the correct format for numbers and dates | | `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown |
| `extensionSidebar` | Enables the extension sidebar |
| `localizationForPlugins` | Enables localization for plugins | | `localizationForPlugins` | Enables localization for plugins |
## Development feature toggles ## Development feature toggles

@ -601,6 +601,7 @@ export {
type PluginMetaInfo, type PluginMetaInfo,
type PluginConfigPageProps, type PluginConfigPageProps,
type PluginConfigPage, type PluginConfigPage,
type ExtensionInfo,
} from './types/plugin'; } from './types/plugin';
export { export {
type InterpolateFunction, type InterpolateFunction,

@ -995,7 +995,7 @@ export interface FeatureToggles {
*/ */
grafanaconThemes?: boolean; grafanaconThemes?: boolean;
/** /**
* Load plugins from CDN synchronously * Loads plugins from CDN synchronously
*/ */
pluginsCDNSyncLoader?: boolean; pluginsCDNSyncLoader?: boolean;
/** /**
@ -1058,7 +1058,7 @@ export interface FeatureToggles {
*/ */
azureMonitorLogsBuilderEditor?: boolean; azureMonitorLogsBuilderEditor?: boolean;
/** /**
* Specify the locale so we can show the correct format for numbers and dates * Specifies the locale so the correct format for numbers and dates can be shown
*/ */
localeFormatPreference?: boolean; localeFormatPreference?: boolean;
/** /**
@ -1066,6 +1066,10 @@ export interface FeatureToggles {
*/ */
unifiedStorageGrpcConnectionPool?: boolean; unifiedStorageGrpcConnectionPool?: boolean;
/** /**
* Enables the extension sidebar
*/
extensionSidebar?: boolean;
/**
* Enables the UI functionality to recover and view deleted alert rules * Enables the UI functionality to recover and view deleted alert rules
* @default true * @default true
*/ */

@ -256,6 +256,7 @@ export const availableIconsIndex = {
'vertical-align-bottom': true, 'vertical-align-bottom': true,
'vertical-align-center': true, 'vertical-align-center': true,
'vertical-align-top': true, 'vertical-align-top': true,
'web-section': true,
'web-section-alt': true, 'web-section-alt': true,
'wrap-text': true, 'wrap-text': true,
wrench: true, wrench: true,

@ -27,12 +27,13 @@ import { getUtilityClassStyles } from './utilityClasses';
interface GlobalStylesProps { interface GlobalStylesProps {
hackNoBackdropBlur?: boolean; hackNoBackdropBlur?: boolean;
isExtensionSidebarOpen?: boolean;
} }
/** @internal */ /** @internal */
export function GlobalStyles(props: GlobalStylesProps) { export function GlobalStyles(props: GlobalStylesProps) {
const theme = useTheme2(); const theme = useTheme2();
const { hackNoBackdropBlur } = props; const { hackNoBackdropBlur, isExtensionSidebarOpen } = props;
return ( return (
<Global <Global
@ -43,7 +44,7 @@ export function GlobalStyles(props: GlobalStylesProps) {
getCodeStyles(theme), getCodeStyles(theme),
getDashDiffStyles(theme), getDashDiffStyles(theme),
getDashboardGridStyles(theme), getDashboardGridStyles(theme),
getElementStyles(theme), getElementStyles(theme, isExtensionSidebarOpen),
getExtraStyles(theme), getExtraStyles(theme),
getFilterTableStyles(theme), getFilterTableStyles(theme),
getFontStyles(theme), getFontStyles(theme),

@ -4,7 +4,13 @@ import { GrafanaTheme2, ThemeTypographyVariant } from '@grafana/data';
import { getFocusStyles } from '../mixins'; import { getFocusStyles } from '../mixins';
export function getElementStyles(theme: GrafanaTheme2) { export function getElementStyles(theme: GrafanaTheme2, isExtensionSidebarOpen?: boolean) {
// in case the sidebar is closed, we want the body to scroll
// react select tries prevent scrolling by setting overflow/padding-right on the body
// Need type assertion here due to the use of !important
// see https://github.com/frenic/csstype/issues/114#issuecomment-697201978
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const bodyOverflow = isExtensionSidebarOpen ? {} : { overflowY: 'auto !important' as 'auto' };
return css({ return css({
'*, *::before, *::after': { '*, *::before, *::after': {
boxSizing: 'inherit', boxSizing: 'inherit',
@ -40,11 +46,6 @@ export function getElementStyles(theme: GrafanaTheme2) {
position: 'unset', position: 'unset',
color: theme.colors.text.primary, color: theme.colors.text.primary,
backgroundColor: theme.colors.background.canvas, backgroundColor: theme.colors.background.canvas,
// react select tries prevent scrolling by setting overflow/padding-right on the body
// Need type assertion here due to the use of !important
// see https://github.com/frenic/csstype/issues/114#issuecomment-697201978
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
overflowY: 'auto !important' as 'auto',
paddingRight: '0 !important', paddingRight: '0 !important',
'@media print': { '@media print': {
overflow: 'visible', overflow: 'visible',
@ -59,6 +60,7 @@ export function getElementStyles(theme: GrafanaTheme2) {
// see https://github.com/rsms/inter/issues/222 // see https://github.com/rsms/inter/issues/222
fontVariantLigatures: 'no-contextual', fontVariantLigatures: 'no-contextual',
...theme.typography.body, ...theme.typography.body,
...bodyOverflow,
}, },
'h1, .h1': getVariantStyles(theme.typography.h1), 'h1, .h1': getVariantStyles(theme.typography.h1),

@ -1698,7 +1698,7 @@ var (
}, },
{ {
Name: "pluginsCDNSyncLoader", Name: "pluginsCDNSyncLoader",
Description: "Load plugins from CDN synchronously", Description: "Loads plugins from CDN synchronously",
Stage: FeatureStageExperimental, Stage: FeatureStageExperimental,
Owner: grafanaPluginsPlatformSquad, Owner: grafanaPluginsPlatformSquad,
}, },
@ -1819,7 +1819,7 @@ var (
}, },
{ {
Name: "localeFormatPreference", Name: "localeFormatPreference",
Description: "Specify the locale so we can show the correct format for numbers and dates", Description: "Specifies the locale so the correct format for numbers and dates can be shown",
Stage: FeatureStageExperimental, Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad, Owner: grafanaFrontendPlatformSquad,
}, },
@ -1831,6 +1831,13 @@ var (
HideFromAdminPage: true, HideFromAdminPage: true,
HideFromDocs: true, HideFromDocs: true,
}, },
{
Name: "extensionSidebar",
Description: "Enables the extension sidebar",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{ {
Name: "alertingRuleRecoverDeleted", Name: "alertingRuleRecoverDeleted",
Description: "Enables the UI functionality to recover and view deleted alert rules", Description: "Enables the UI functionality to recover and view deleted alert rules",

@ -241,6 +241,7 @@ unifiedStorageHistoryPruner,experimental,@grafana/search-and-storage,false,false
azureMonitorLogsBuilderEditor,preview,@grafana/partner-datasources,false,false,false azureMonitorLogsBuilderEditor,preview,@grafana/partner-datasources,false,false,false
localeFormatPreference,experimental,@grafana/grafana-frontend-platform,false,false,false localeFormatPreference,experimental,@grafana/grafana-frontend-platform,false,false,false
unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false
extensionSidebar,experimental,@grafana/observability-logs,false,false,true
alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true
xrayApplicationSignals,experimental,@grafana/aws-datasources,false,false,true xrayApplicationSignals,experimental,@grafana/aws-datasources,false,false,true
multiTenantTempCredentials,experimental,@grafana/aws-datasources,false,false,false multiTenantTempCredentials,experimental,@grafana/aws-datasources,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
241 azureMonitorLogsBuilderEditor preview @grafana/partner-datasources false false false
242 localeFormatPreference experimental @grafana/grafana-frontend-platform false false false
243 unifiedStorageGrpcConnectionPool experimental @grafana/search-and-storage false false false
244 extensionSidebar experimental @grafana/observability-logs false false true
245 alertingRuleRecoverDeleted GA @grafana/alerting-squad false false true
246 xrayApplicationSignals experimental @grafana/aws-datasources false false true
247 multiTenantTempCredentials experimental @grafana/aws-datasources false false false

@ -908,7 +908,7 @@ const (
FlagGrafanaconThemes = "grafanaconThemes" FlagGrafanaconThemes = "grafanaconThemes"
// FlagPluginsCDNSyncLoader // FlagPluginsCDNSyncLoader
// Load plugins from CDN synchronously // Loads plugins from CDN synchronously
FlagPluginsCDNSyncLoader = "pluginsCDNSyncLoader" FlagPluginsCDNSyncLoader = "pluginsCDNSyncLoader"
// FlagAlertingJiraIntegration // FlagAlertingJiraIntegration
@ -968,13 +968,17 @@ const (
FlagAzureMonitorLogsBuilderEditor = "azureMonitorLogsBuilderEditor" FlagAzureMonitorLogsBuilderEditor = "azureMonitorLogsBuilderEditor"
// FlagLocaleFormatPreference // FlagLocaleFormatPreference
// Specify the locale so we can show the correct format for numbers and dates // Specifies the locale so the correct format for numbers and dates can be shown
FlagLocaleFormatPreference = "localeFormatPreference" FlagLocaleFormatPreference = "localeFormatPreference"
// FlagUnifiedStorageGrpcConnectionPool // FlagUnifiedStorageGrpcConnectionPool
// Enables the unified storage grpc connection pool // Enables the unified storage grpc connection pool
FlagUnifiedStorageGrpcConnectionPool = "unifiedStorageGrpcConnectionPool" FlagUnifiedStorageGrpcConnectionPool = "unifiedStorageGrpcConnectionPool"
// FlagExtensionSidebar
// Enables the extension sidebar
FlagExtensionSidebar = "extensionSidebar"
// FlagAlertingRuleRecoverDeleted // FlagAlertingRuleRecoverDeleted
// Enables the UI functionality to recover and view deleted alert rules // Enables the UI functionality to recover and view deleted alert rules
FlagAlertingRuleRecoverDeleted = "alertingRuleRecoverDeleted" FlagAlertingRuleRecoverDeleted = "alertingRuleRecoverDeleted"

@ -437,21 +437,6 @@
"expression": "false" "expression": "false"
} }
}, },
{
"metadata": {
"name": "alertingRuleSequentialEvaluation",
"resourceVersion": "1742580089675",
"creationTimestamp": "2025-03-21T18:01:29Z",
"deletionTimestamp": "2025-03-31T17:26:04Z"
},
"spec": {
"description": "Enables the alerting rule sequential evaluation feature",
"stage": "preview",
"codeowner": "@grafana/alerting-squad",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{ {
"metadata": { "metadata": {
"name": "alertingRuleRecoverDeleted", "name": "alertingRuleRecoverDeleted",
@ -471,6 +456,21 @@
"expression": "true" "expression": "true"
} }
}, },
{
"metadata": {
"name": "alertingRuleSequentialEvaluation",
"resourceVersion": "1742580089675",
"creationTimestamp": "2025-03-21T18:01:29Z",
"deletionTimestamp": "2025-03-31T17:26:04Z"
},
"spec": {
"description": "Enables the alerting rule sequential evaluation feature",
"stage": "preview",
"codeowner": "@grafana/alerting-squad",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{ {
"metadata": { "metadata": {
"name": "alertingRuleVersionHistoryRestore", "name": "alertingRuleVersionHistoryRestore",
@ -1739,6 +1739,19 @@
"requiresRestart": true "requiresRestart": true
} }
}, },
{
"metadata": {
"name": "extensionSidebar",
"resourceVersion": "1743071445006",
"creationTimestamp": "2025-03-27T10:30:45Z"
},
"spec": {
"description": "Enables the extension sidebar",
"stage": "experimental",
"codeowner": "@grafana/observability-logs",
"frontend": true
}
},
{ {
"metadata": { "metadata": {
"name": "externalCorePlugins", "name": "externalCorePlugins",
@ -2496,11 +2509,14 @@
{ {
"metadata": { "metadata": {
"name": "localeFormatPreference", "name": "localeFormatPreference",
"resourceVersion": "1742397804851", "resourceVersion": "1743662997585",
"creationTimestamp": "2025-03-19T15:23:24Z" "creationTimestamp": "2025-03-19T15:23:24Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-04-03 06:49:57.585909 +0000 UTC"
}
}, },
"spec": { "spec": {
"description": "Specify the locale so we can show the correct format for numbers and dates", "description": "Specifies the locale so the correct format for numbers and dates can be shown",
"stage": "experimental", "stage": "experimental",
"codeowner": "@grafana/grafana-frontend-platform" "codeowner": "@grafana/grafana-frontend-platform"
} }
@ -3302,11 +3318,14 @@
{ {
"metadata": { "metadata": {
"name": "pluginsCDNSyncLoader", "name": "pluginsCDNSyncLoader",
"resourceVersion": "1737026684018", "resourceVersion": "1743664300735",
"creationTimestamp": "2025-01-16T11:24:44Z" "creationTimestamp": "2025-01-16T11:24:44Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-04-03 07:11:40.735562 +0000 UTC"
}
}, },
"spec": { "spec": {
"description": "Load plugins from CDN synchronously", "description": "Loads plugins from CDN synchronously",
"stage": "experimental", "stage": "experimental",
"codeowner": "@grafana/plugins-platform-backend" "codeowner": "@grafana/plugins-platform-backend"
} }

@ -11,12 +11,14 @@ import {
SidecarContext_EXPERIMENTAL, SidecarContext_EXPERIMENTAL,
sidecarServiceSingleton_EXPERIMENTAL, sidecarServiceSingleton_EXPERIMENTAL,
} from '@grafana/runtime'; } from '@grafana/runtime';
import { ErrorBoundaryAlert, GlobalStyles, PortalContainer, TimeRangeProvider } from '@grafana/ui'; import { ErrorBoundaryAlert, PortalContainer, TimeRangeProvider } 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 { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled'; import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled';
import { GrafanaApp } from './app'; import { GrafanaApp } from './app';
import { ExtensionSidebarContextProvider } from './core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider';
import { GlobalStylesWrapper } from './core/components/AppChrome/ExtensionSidebar/GlobalStylesWrapper';
import { GrafanaContext } from './core/context/GrafanaContext'; import { GrafanaContext } from './core/context/GrafanaContext';
import { GrafanaRouteWrapper } from './core/navigation/GrafanaRoute'; import { GrafanaRouteWrapper } from './core/navigation/GrafanaRoute';
import { RouteDescriptor } from './core/navigation/types'; import { RouteDescriptor } from './core/navigation/types';
@ -110,6 +112,9 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
}; };
const MaybeTimeRangeProvider = config.featureToggles.timeRangeProvider ? TimeRangeProvider : Fragment; const MaybeTimeRangeProvider = config.featureToggles.timeRangeProvider ? TimeRangeProvider : Fragment;
const MaybeExtensionSidebarProvider = config.featureToggles.extensionSidebar
? ExtensionSidebarContextProvider
: Fragment;
return ( return (
<Provider store={store}> <Provider store={store}>
@ -121,20 +126,22 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
actions={[]} actions={[]}
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }} options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
> >
<GlobalStyles hackNoBackdropBlur={config.featureToggles.noBackdropBlur} />
<MaybeTimeRangeProvider> <MaybeTimeRangeProvider>
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}> <SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
<ScopesContextProvider> <ScopesContextProvider>
<ExtensionRegistriesProvider registries={pluginExtensionRegistries}> <ExtensionRegistriesProvider registries={pluginExtensionRegistries}>
<div className="grafana-app"> <MaybeExtensionSidebarProvider>
{config.featureToggles.appSidecar ? ( <GlobalStylesWrapper />
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} /> <div className="grafana-app">
) : ( {config.featureToggles.appSidecar ? (
<RouterWrapper {...routerWrapperProps} /> <ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
)} ) : (
<LiveConnectionWarning /> <RouterWrapper {...routerWrapperProps} />
<PortalContainer /> )}
</div> <LiveConnectionWarning />
<PortalContainer />
</div>
</MaybeExtensionSidebarProvider>
</ExtensionRegistriesProvider> </ExtensionRegistriesProvider>
</ScopesContextProvider> </ScopesContextProvider>
</SidecarContext_EXPERIMENTAL.Provider> </SidecarContext_EXPERIMENTAL.Provider>

@ -4,7 +4,7 @@ import { PropsWithChildren, useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { locationSearchToObject, locationService, useScopes } from '@grafana/runtime'; import { locationSearchToObject, locationService, useScopes } from '@grafana/runtime';
import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui'; import { LinkButton, useStyles2, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext'; import { useGrafana } from 'app/core/context/GrafanaContext';
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
import { Trans } from 'app/core/internationalization'; import { Trans } from 'app/core/internationalization';
@ -14,6 +14,8 @@ import { ScopesDashboards } from 'app/features/scopes/dashboards/ScopesDashboard
import { AppChromeMenu } from './AppChromeMenu'; import { AppChromeMenu } from './AppChromeMenu';
import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './AppChromeService'; import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './AppChromeService';
import { EXTENSION_SIDEBAR_WIDTH, ExtensionSidebar } from './ExtensionSidebar/ExtensionSidebar';
import { useExtensionSidebarContext } from './ExtensionSidebar/ExtensionSidebarProvider';
import { MegaMenu, MENU_WIDTH } from './MegaMenu/MegaMenu'; import { MegaMenu, MENU_WIDTH } from './MegaMenu/MegaMenu';
import { useMegaMenuFocusHelper } from './MegaMenu/utils'; import { useMegaMenuFocusHelper } from './MegaMenu/utils';
import { ReturnToPrevious } from './ReturnToPrevious/ReturnToPrevious'; import { ReturnToPrevious } from './ReturnToPrevious/ReturnToPrevious';
@ -25,10 +27,10 @@ export interface Props extends PropsWithChildren<{}> {}
export function AppChrome({ children }: Props) { export function AppChrome({ children }: Props) {
const { chrome } = useGrafana(); const { chrome } = useGrafana();
const { isOpen: isExtensionSidebarOpen, isEnabled: isExtensionSidebarEnabled } = useExtensionSidebarContext();
const state = chrome.useState(); const state = chrome.useState();
const theme = useTheme2(); const theme = useTheme2();
const scopes = useScopes(); const scopes = useScopes();
const styles = useStyles2(getStyles, Boolean(state.actions) || !!scopes?.state.enabled);
const dockedMenuBreakpoint = theme.breakpoints.values.xl; const dockedMenuBreakpoint = theme.breakpoints.values.xl;
const dockedMenuLocalStorageState = store.getBool(DOCKED_LOCAL_STORAGE_KEY, true); const dockedMenuLocalStorageState = store.getBool(DOCKED_LOCAL_STORAGE_KEY, true);
@ -36,6 +38,7 @@ export function AppChrome({ children }: Props) {
const isScopesDashboardsOpen = Boolean( const isScopesDashboardsOpen = Boolean(
scopes?.state.enabled && scopes?.state.drawerOpened && !scopes?.state.readOnly scopes?.state.enabled && scopes?.state.drawerOpened && !scopes?.state.readOnly
); );
const styles = useStyles2(getStyles, Boolean(state.actions) || !!scopes?.state.enabled);
useMediaQueryChange({ useMediaQueryChange({
breakpoint: dockedMenuBreakpoint, breakpoint: dockedMenuBreakpoint,
onChange: (e) => { onChange: (e) => {
@ -52,6 +55,7 @@ export function AppChrome({ children }: Props) {
const contentClass = cx({ const contentClass = cx({
[styles.content]: true, [styles.content]: true,
[styles.contentChromeless]: state.chromeless, [styles.contentChromeless]: state.chromeless,
[styles.contentWithSidebar]: isExtensionSidebarOpen && !state.chromeless,
}); });
const handleMegaMenu = () => { const handleMegaMenu = () => {
@ -106,7 +110,7 @@ export function AppChrome({ children }: Props) {
</> </>
)} )}
<div className={contentClass}> <div className={contentClass}>
<div className={styles.panes}> <div className={cx(styles.panes, { [styles.panesWithSidebar]: isExtensionSidebarOpen })}>
{!state.chromeless && ( {!state.chromeless && (
<div <div
className={cx(styles.scopesDashboardsContainer, { className={cx(styles.scopesDashboardsContainer, {
@ -120,11 +124,17 @@ export function AppChrome({ children }: Props) {
className={cx(styles.pageContainer, { className={cx(styles.pageContainer, {
[styles.pageContainerMenuDocked]: menuDockedAndOpen || isScopesDashboardsOpen, [styles.pageContainerMenuDocked]: menuDockedAndOpen || isScopesDashboardsOpen,
[styles.pageContainerMenuDockedScopes]: menuDockedAndOpen && isScopesDashboardsOpen, [styles.pageContainerMenuDockedScopes]: menuDockedAndOpen && isScopesDashboardsOpen,
[styles.pageContainerWithSidebar]: !state.chromeless && isExtensionSidebarOpen,
})} })}
id="pageContent" id="pageContent"
> >
{children} {children}
</main> </main>
{!state.chromeless && isExtensionSidebarEnabled && isExtensionSidebarOpen && (
<div className={styles.sidebarContainer}>
<ExtensionSidebar />
</div>
)}
</div> </div>
</div> </div>
{!state.chromeless && !state.megaMenuDocked && <AppChromeMenu />} {!state.chromeless && !state.megaMenuDocked && <AppChromeMenu />}
@ -145,6 +155,10 @@ const getStyles = (theme: GrafanaTheme2, hasActions: boolean) => {
flexGrow: 1, flexGrow: 1,
height: 'auto', height: 'auto',
}), }),
contentWithSidebar: css({
height: '100vh',
overflow: 'hidden',
}),
contentChromeless: css({ contentChromeless: css({
paddingTop: 0, paddingTop: 0,
}), }),
@ -188,6 +202,11 @@ const getStyles = (theme: GrafanaTheme2, hasActions: boolean) => {
flexGrow: 1, flexGrow: 1,
label: 'page-panes', label: 'page-panes',
}), }),
panesWithSidebar: css({
height: '100%',
overflow: 'hidden',
position: 'relative',
}),
pageContainerMenuDocked: css({ pageContainerMenuDocked: css({
paddingLeft: MENU_WIDTH, paddingLeft: MENU_WIDTH,
}), }),
@ -200,6 +219,12 @@ const getStyles = (theme: GrafanaTheme2, hasActions: boolean) => {
flexDirection: 'column', flexDirection: 'column',
flexGrow: 1, flexGrow: 1,
}), }),
pageContainerWithSidebar: css({
overflow: 'auto',
height: '100%',
minHeight: 0,
maxWidth: `calc(100% - ${EXTENSION_SIDEBAR_WIDTH})`,
}),
skipLink: css({ skipLink: css({
position: 'fixed', position: 'fixed',
top: -1000, top: -1000,
@ -210,5 +235,11 @@ const getStyles = (theme: GrafanaTheme2, hasActions: boolean) => {
zIndex: theme.zIndex.portal, zIndex: theme.zIndex.portal,
}, },
}), }),
sidebarContainer: css({
position: 'fixed',
height: `calc(100% - ${TOP_BAR_LEVEL_HEIGHT}px)`,
zIndex: 2,
right: 0,
}),
}; };
}; };

@ -0,0 +1,64 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { usePluginComponents } from '@grafana/runtime';
import { useTheme2 } from '@grafana/ui';
import {
EXTENSION_SIDEBAR_EXTENSION_POINT_ID,
getComponentMetaFromComponentId,
useExtensionSidebarContext,
} from './ExtensionSidebarProvider';
export const EXTENSION_SIDEBAR_WIDTH = '300px';
export function ExtensionSidebar() {
const styles = getStyles(useTheme2());
const { dockedComponentId, isEnabled } = useExtensionSidebarContext();
const { components, isLoading } = usePluginComponents({ extensionPointId: EXTENSION_SIDEBAR_EXTENSION_POINT_ID });
if (isLoading || !dockedComponentId || !isEnabled) {
return null;
}
const dockedMeta = getComponentMetaFromComponentId(dockedComponentId);
if (!dockedMeta) {
return null;
}
const ExtensionComponent = components.find(
(c) => c.meta.pluginId === dockedMeta.pluginId && c.meta.title === dockedMeta.componentTitle
);
if (!ExtensionComponent) {
return null;
}
return (
<div className={styles.sidebarWrapper}>
<div className={styles.content}>
<ExtensionComponent />
</div>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
sidebarWrapper: css({
backgroundColor: theme.colors.background.primary,
borderLeft: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
padding: theme.spacing(1),
width: EXTENSION_SIDEBAR_WIDTH,
height: '100%',
}),
content: css({
flex: 1,
minHeight: 0,
overflow: 'auto',
}),
};
};

@ -0,0 +1,241 @@
import { render, screen, act } from '@testing-library/react';
// import { render } from 'test/test-utils';
import { store } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
import {
ExtensionSidebarContextProvider,
useExtensionSidebarContext,
getComponentIdFromComponentMeta,
getComponentMetaFromComponentId,
EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY,
} from './ExtensionSidebarProvider';
// Mock the store
jest.mock('@grafana/data', () => ({
...jest.requireActual('@grafana/data'),
store: {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
},
}));
// Mock the extension point plugin meta
jest.mock('app/features/plugins/extensions/utils', () => ({
...jest.requireActual('app/features/plugins/extensions/utils'),
getExtensionPointPluginMeta: jest.fn(),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
...jest.requireActual('@grafana/runtime').config.featureToggles,
extensionSidebar: true,
},
},
}));
const mockComponent = {
title: 'Test Component',
description: 'Test Description',
targets: [],
};
const mockPluginMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [mockComponent],
};
describe('ExtensionSidebarProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(new Map([[mockPluginMeta.pluginId, mockPluginMeta]]));
jest.replaceProperty(config.featureToggles, 'extensionSidebar', true);
});
const TestComponent = () => {
const context = useExtensionSidebarContext();
return (
<div>
<div data-testid="is-open">{context.isOpen.toString()}</div>
<div data-testid="docked-component-id">{context.dockedComponentId || 'undefined'}</div>
<div data-testid="available-components-size">{context.availableComponents.size}</div>
<div data-testid="plugin-ids">{Array.from(context.availableComponents.keys()).join(', ')}</div>
<div data-testid="is-enabled">{context.isEnabled.toString()}</div>
</div>
);
};
it('should provide default context values', () => {
render(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
expect(screen.getByTestId('is-open')).toHaveTextContent('false');
expect(screen.getByTestId('docked-component-id')).toHaveTextContent('undefined');
expect(screen.getByTestId('available-components-size')).toHaveTextContent('1');
expect(screen.getByTestId('is-enabled')).toHaveTextContent('true');
});
it('should have empty available components when feature toggle is disabled', () => {
jest.replaceProperty(config.featureToggles, 'extensionSidebar', false);
render(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
expect(screen.getByTestId('is-enabled')).toHaveTextContent('false');
expect(screen.getByTestId('available-components-size')).toHaveTextContent('0');
});
it('should load docked component from storage if available', () => {
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent);
(store.get as jest.Mock).mockReturnValue(componentId);
render(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
expect(screen.getByTestId('is-open')).toHaveTextContent('true');
expect(screen.getByTestId('docked-component-id')).toHaveTextContent(componentId);
});
it('should not load docked component from storage if feature toggle is disabled', () => {
jest.replaceProperty(config.featureToggles, 'extensionSidebar', false);
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent);
(store.get as jest.Mock).mockReturnValue(componentId);
render(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
expect(screen.getByTestId('is-open')).toHaveTextContent('false');
expect(screen.getByTestId('docked-component-id')).toHaveTextContent('undefined');
});
it('should update storage when docked component changes', () => {
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent);
const TestComponentWithActions = () => {
const context = useExtensionSidebarContext();
return (
<div>
<button
onClick={() => {
context.setDockedComponentId(componentId);
}}
>
Set Component
</button>
<button
onClick={() => {
context.setDockedComponentId(undefined);
}}
>
Clear Component
</button>
</div>
);
};
render(
<ExtensionSidebarContextProvider>
<TestComponentWithActions />
</ExtensionSidebarContextProvider>
);
act(() => {
screen.getByText('Set Component').click();
});
expect(store.set).toHaveBeenCalledWith(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY, componentId);
act(() => {
screen.getByText('Clear Component').click();
});
expect(store.delete).toHaveBeenCalledWith(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY);
});
it('should only include permitted plugins in available components', () => {
const permittedPluginMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [mockComponent],
};
const prohibitedPluginMeta = {
pluginId: 'disabled-plugin',
addedComponents: [mockComponent],
};
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(
new Map([
[permittedPluginMeta.pluginId, permittedPluginMeta],
[prohibitedPluginMeta.pluginId, prohibitedPluginMeta],
])
);
render(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
// Should only include the enabled plugin
expect(screen.getByTestId('available-components-size')).toHaveTextContent('1');
expect(screen.getByTestId('plugin-ids')).toHaveTextContent(permittedPluginMeta.pluginId);
});
});
describe('Utility Functions', () => {
describe('getComponentIdFromComponentMeta', () => {
it('should create a valid component ID', () => {
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent);
expect(componentId).toBe(
JSON.stringify({ pluginId: mockPluginMeta.pluginId, componentTitle: mockComponent.title })
);
});
});
describe('getComponentMetaFromComponentId', () => {
it('should parse a valid component ID', () => {
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent);
const meta = getComponentMetaFromComponentId(componentId);
expect(meta).toEqual({
pluginId: mockPluginMeta.pluginId,
componentTitle: mockComponent.title,
});
});
it('should return undefined for invalid JSON', () => {
const meta = getComponentMetaFromComponentId('invalid-json');
expect(meta).toBeUndefined();
});
it('should return undefined for missing required fields', () => {
const meta = getComponentMetaFromComponentId(JSON.stringify({ pluginId: mockPluginMeta.pluginId }));
expect(meta).toBeUndefined();
});
it('should return undefined for wrong field types', () => {
const meta = getComponentMetaFromComponentId(JSON.stringify({ pluginId: 123, componentTitle: 'Test Component' }));
expect(meta).toBeUndefined();
});
});
});

@ -0,0 +1,132 @@
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
import { store, type ExtensionInfo } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ExtensionPointPluginMeta, getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
export const EXTENSION_SIDEBAR_EXTENSION_POINT_ID = 'grafana/extension-sidebar/v0-alpha';
export const EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.extensionSidebarDocked';
const PERMITTED_EXTENSION_SIDEBAR_PLUGINS = ['grafana-investigations-app'];
type ExtensionSidebarContextType = {
/**
* Whether the extension sidebar is enabled.
*/
isEnabled: boolean;
/**
* Whether the extension sidebar is open.
*/
isOpen: boolean;
/**
* The id of the component that is currently docked in the sidebar. If the id is undefined, nothing will be rendered.
*/
dockedComponentId: string | undefined;
/**
* Sest the id of the component that will be rendered in the extension sidebar.
*/
setDockedComponentId: (componentId: string | undefined) => void;
/**
* A map of all components that are available for the extension point.
*/
availableComponents: ExtensionPointPluginMeta;
};
export const ExtensionSidebarContext = createContext<ExtensionSidebarContextType>({
isEnabled: !!config.featureToggles.extensionSidebar,
isOpen: false,
dockedComponentId: undefined,
setDockedComponentId: () => {},
availableComponents: new Map(),
});
export function useExtensionSidebarContext() {
return useContext(ExtensionSidebarContext);
}
interface ExtensionSidebarContextProps {
children: ReactNode;
}
export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarContextProps) => {
const storedDockedPluginId = store.get(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY);
const isEnabled = !!config.featureToggles.extensionSidebar;
// get all components for this extension point, but only for the permitted plugins
// if the extension sidebar is not enabled, we will return an empty map
const availableComponents = isEnabled
? new Map(
Array.from(getExtensionPointPluginMeta(EXTENSION_SIDEBAR_EXTENSION_POINT_ID).entries()).filter(([pluginId]) =>
PERMITTED_EXTENSION_SIDEBAR_PLUGINS.includes(pluginId)
)
)
: new Map<
string,
{
readonly addedComponents: ExtensionInfo[];
readonly addedLinks: ExtensionInfo[];
}
>();
// check if the stored docked component is still available
let defaultDockedComponentId: string | undefined;
if (storedDockedPluginId) {
const dockedMeta = getComponentMetaFromComponentId(storedDockedPluginId);
if (dockedMeta) {
const plugin = availableComponents.get(dockedMeta.pluginId);
if (plugin) {
const component = plugin.addedComponents.find((c) => c.title === dockedMeta.componentTitle);
if (component) {
defaultDockedComponentId = storedDockedPluginId;
}
}
}
}
const [dockedComponentId, setDockedComponentId] = useState<string | undefined>(defaultDockedComponentId);
// update the stored docked component id when it changes
useEffect(() => {
if (dockedComponentId) {
store.set(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY, dockedComponentId);
} else {
store.delete(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY);
}
}, [dockedComponentId]);
return (
<ExtensionSidebarContext.Provider
value={{
isEnabled,
isOpen: isEnabled && dockedComponentId !== undefined,
dockedComponentId,
setDockedComponentId,
availableComponents,
}}
>
{children}
</ExtensionSidebarContext.Provider>
);
};
export function getComponentIdFromComponentMeta(pluginId: string, component: ExtensionInfo) {
return JSON.stringify({ pluginId, componentTitle: component.title });
}
export function getComponentMetaFromComponentId(
componentId: string
): { pluginId: string; componentTitle: string } | undefined {
try {
const parsed = JSON.parse(componentId);
if (
typeof parsed === 'object' &&
parsed !== null &&
'pluginId' in parsed &&
'componentTitle' in parsed &&
typeof parsed.pluginId === 'string' &&
typeof parsed.componentTitle === 'string'
) {
return parsed;
}
return undefined;
} catch (error) {
return undefined;
}
}

@ -0,0 +1,209 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { store } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
import { ExtensionSidebarContextProvider, useExtensionSidebarContext } from './ExtensionSidebarProvider';
import { ExtensionToolbarItem } from './ExtensionToolbarItem';
// Mock the extension point plugin meta
jest.mock('app/features/plugins/extensions/utils', () => ({
...jest.requireActual('app/features/plugins/extensions/utils'),
getExtensionPointPluginMeta: jest.fn(),
}));
// Mock store
jest.mock('@grafana/data', () => ({
...jest.requireActual('@grafana/data'),
store: {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
},
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
...jest.requireActual('@grafana/runtime').config.featureToggles,
extensionSidebar: true,
},
},
}));
const mockComponent = {
title: 'Test Component',
description: 'Test Description',
targets: [],
};
const mockPluginMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [mockComponent],
};
const TestComponent = () => {
const { isOpen, dockedComponentId } = useExtensionSidebarContext();
return (
<div>
<div data-testid="is-open">{isOpen.toString()}</div>
<div data-testid="docked-component-id">{dockedComponentId}</div>
</div>
);
};
const setup = () => {
return render(
<ExtensionSidebarContextProvider>
<ExtensionToolbarItem />
<TestComponent />
</ExtensionSidebarContextProvider>
);
};
describe('ExtensionToolbarItem', () => {
beforeEach(() => {
jest.clearAllMocks();
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(new Map([[mockPluginMeta.pluginId, mockPluginMeta]]));
(store.get as jest.Mock).mockClear();
(store.set as jest.Mock).mockClear();
(store.delete as jest.Mock).mockClear();
jest.replaceProperty(config.featureToggles, 'extensionSidebar', true);
});
it('should not render when feature toggle is disabled', () => {
jest.replaceProperty(config.featureToggles, 'extensionSidebar', false);
setup();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('should not render when no components are available', () => {
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(new Map());
setup();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('should render a single button when only one component is available', () => {
setup();
const button = screen.getByTestId('extension-toolbar-button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('aria-label', mockComponent.description);
expect(screen.getByTestId('is-open')).toHaveTextContent('false');
expect(screen.getByTestId('docked-component-id')).toHaveTextContent('');
});
it('should toggle the sidebar when clicking a single component button', async () => {
setup();
const button = screen.getByTestId('extension-toolbar-button');
await userEvent.click(button);
expect(screen.getByTestId('is-open')).toHaveTextContent('true');
expect(screen.getByTestId('docked-component-id')).toHaveTextContent(mockComponent.title);
});
it('should render a dropdown menu when multiple components are available', async () => {
const multipleComponentsMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [
{ ...mockComponent, title: 'Component 1' },
{ ...mockComponent, title: 'Component 2' },
],
};
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(
new Map([[multipleComponentsMeta.pluginId, multipleComponentsMeta]])
);
setup();
const button = screen.getByTestId('extension-toolbar-button');
expect(button).toBeInTheDocument();
await userEvent.click(button);
expect(screen.getAllByRole('menuitem')).toHaveLength(multipleComponentsMeta.addedComponents.length);
expect(screen.getByText(multipleComponentsMeta.addedComponents[0].title)).toBeInTheDocument();
expect(screen.getByText(multipleComponentsMeta.addedComponents[1].title)).toBeInTheDocument();
});
it('should show menu items when clicking the dropdown button', async () => {
const multipleComponentsMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [
{ ...mockComponent, title: 'Component 1' },
{ ...mockComponent, title: 'Component 2' },
],
};
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(
new Map([[multipleComponentsMeta.pluginId, multipleComponentsMeta]])
);
setup();
const button = screen.getByTestId('extension-toolbar-button');
await userEvent.click(button);
// Menu items should be visible
expect(screen.getByText('Component 1')).toBeInTheDocument();
expect(screen.getByText('Component 2')).toBeInTheDocument();
expect(screen.getByTestId('is-open')).toHaveTextContent('false');
});
it('should toggle the sidebar when clicking a menu item', async () => {
const multipleComponentsMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [
{ ...mockComponent, title: 'Component 1' },
{ ...mockComponent, title: 'Component 2' },
],
};
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(
new Map([[multipleComponentsMeta.pluginId, multipleComponentsMeta]])
);
setup();
// Open the dropdown
const button = screen.getByTestId('extension-toolbar-button');
await userEvent.click(button);
// Click a menu item
await userEvent.click(screen.getByText('Component 1'));
// The button should now be active
expect(screen.getByTestId('is-open')).toHaveTextContent('true');
expect(screen.getByTestId('docked-component-id')).toHaveTextContent('Component 1');
});
it('should close the sidebar when clicking an active menu item', async () => {
const multipleComponentsMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [
{ ...mockComponent, title: 'Component 1' },
{ ...mockComponent, title: 'Component 2' },
],
};
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(
new Map([[multipleComponentsMeta.pluginId, multipleComponentsMeta]])
);
setup();
const button = screen.getByTestId('extension-toolbar-button');
await userEvent.click(button);
await userEvent.click(screen.getByText('Component 1'));
await userEvent.click(button);
await userEvent.click(screen.getByText('Component 1'));
expect(screen.getByTestId('is-open')).toHaveTextContent('false');
});
});

@ -0,0 +1,74 @@
import { useState } from 'react';
import { Dropdown, Menu, ToolbarButton } from '@grafana/ui';
import { getComponentIdFromComponentMeta, useExtensionSidebarContext } from './ExtensionSidebarProvider';
export function ExtensionToolbarItem() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { availableComponents, dockedComponentId, setDockedComponentId, isOpen, isEnabled } =
useExtensionSidebarContext();
if (!isEnabled || availableComponents.size === 0) {
return null;
}
// get a flat list of all components with their pluginId
const components = Array.from(availableComponents.entries()).flatMap(([pluginId, { addedComponents }]) =>
addedComponents.map((c) => ({ ...c, pluginId }))
);
if (components.length === 0) {
return null;
}
if (components.length === 1) {
return (
<ToolbarButton
icon="web-section"
data-testid="extension-toolbar-button"
variant={isOpen ? 'active' : 'default'}
tooltip={components[0].description}
onClick={() => {
if (isOpen) {
setDockedComponentId(undefined);
} else {
setDockedComponentId(getComponentIdFromComponentMeta(components[0].pluginId, components[0]));
}
}}
/>
);
}
const MenuItems = (
<Menu>
{components.map((c) => {
const id = getComponentIdFromComponentMeta(c.pluginId, c);
return (
<Menu.Item
key={id}
active={dockedComponentId === id}
label={c.title}
onClick={() => {
if (isOpen && dockedComponentId === id) {
setDockedComponentId(undefined);
} else {
setDockedComponentId(id);
}
}}
/>
);
})}
</Menu>
);
return (
<Dropdown overlay={MenuItems} onVisibleChange={setIsMenuOpen} placement="bottom-end">
<ToolbarButton
data-testid="extension-toolbar-button"
icon="web-section"
isOpen={isMenuOpen}
variant={isOpen ? 'active' : 'default'}
/>
</Dropdown>
);
}

@ -0,0 +1,14 @@
import { config } from '@grafana/runtime';
import { GlobalStyles } from '@grafana/ui';
import { useExtensionSidebarContext } from './ExtensionSidebarProvider';
/**
* This component is used to wrap the GlobalStyles component and pass the isExtensionSidebarOpen prop to it.
* Since GlobalStyles is imported from @grafana/ui, we need to wrap it in a component to use the useExtensionSidebarContext hook.
*/
export const GlobalStylesWrapper = () => {
const { isOpen } = useExtensionSidebarContext();
return <GlobalStyles hackNoBackdropBlur={config.featureToggles.noBackdropBlur} isExtensionSidebarOpen={isOpen} />;
};

@ -15,6 +15,7 @@ import { useSelector } from 'app/types';
import { Branding } from '../../Branding/Branding'; import { Branding } from '../../Branding/Branding';
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
import { buildBreadcrumbs } from '../../Breadcrumbs/utils'; import { buildBreadcrumbs } from '../../Breadcrumbs/utils';
import { ExtensionToolbarItem } from '../ExtensionSidebar/ExtensionToolbarItem';
import { HistoryContainer } from '../History/HistoryContainer'; import { HistoryContainer } from '../History/HistoryContainer';
import { enrichHelpItem } from '../MegaMenu/utils'; import { enrichHelpItem } from '../MegaMenu/utils';
import { QuickAdd } from '../QuickAdd/QuickAdd'; import { QuickAdd } from '../QuickAdd/QuickAdd';
@ -88,6 +89,7 @@ export const SingleTopBar = memo(function SingleTopBar({
/> />
{!contextSrv.user.isSignedIn && <SignInLink />} {!contextSrv.user.isSignedIn && <SignInLink />}
{config.featureToggles.inviteUserExperimental && <InviteUserButton />} {config.featureToggles.inviteUserExperimental && <InviteUserButton />}
{config.featureToggles.extensionSidebar && <ExtensionToolbarItem />}
{profileNode && <ProfileButton profileNode={profileNode} />} {profileNode && <ProfileButton profileNode={profileNode} />}
</Stack> </Stack>
</div> </div>

@ -1,10 +1,12 @@
import { createContext, useCallback, useContext } from 'react'; import { createContext, useCallback, useContext } from 'react';
import { useObservable } from 'react-use'; import { useObservable } from 'react-use';
import { of } from 'rxjs';
import { GrafanaConfig } from '@grafana/data'; import { GrafanaConfig } from '@grafana/data';
import { LocationService, locationService, BackendSrv } from '@grafana/runtime'; import { LocationService, locationService, BackendSrv } from '@grafana/runtime';
import { AppChromeService } from '../components/AppChrome/AppChromeService'; import { AppChromeService } from '../components/AppChrome/AppChromeService';
import { useExtensionSidebarContext } from '../components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider';
import { NewFrontendAssetsChecker } from '../services/NewFrontendAssetsChecker'; import { NewFrontendAssetsChecker } from '../services/NewFrontendAssetsChecker';
import { KeybindingSrv } from '../services/keybindingSrv'; import { KeybindingSrv } from '../services/keybindingSrv';
@ -45,5 +47,8 @@ export function useReturnToPreviousInternal() {
export function useChromeHeaderHeight() { export function useChromeHeaderHeight() {
const { chrome } = useGrafana(); const { chrome } = useGrafana();
return useObservable(chrome.headerHeightObservable, 0); // if the extension sidebar is open, the inner pane will be scrollable, thus we need to set the header height to 0
const { isOpen: isExtensionSidebarOpen } = useExtensionSidebarContext();
return useObservable(isExtensionSidebarOpen ? of(0) : chrome.headerHeightObservable, 0);
} }

@ -17,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({
delete: deleteMock, delete: deleteMock,
}), }),
config: { config: {
...jest.requireActual('@grafana/runtime').config,
panels: { panels: {
test: { test: {
id: 'test', id: 'test',

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
import { type Unsubscribable } from 'rxjs'; import { type Unsubscribable } from 'rxjs';
import { dateTime, usePluginContext, PluginLoadingStrategy } from '@grafana/data'; 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 appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/events'; import { ShowModalReactEvent } from 'app/types/events';
@ -18,6 +18,7 @@ import {
getAppPluginConfigs, getAppPluginConfigs,
getAppPluginIdFromExposedComponentId, getAppPluginIdFromExposedComponentId,
getAppPluginDependencies, getAppPluginDependencies,
getExtensionPointPluginMeta,
} from './utils'; } from './utils';
jest.mock('app/features/plugins/pluginSettings', () => ({ jest.mock('app/features/plugins/pluginSettings', () => ({
@ -982,4 +983,121 @@ describe('Plugin Extensions / Utils', () => {
expect(appPluginIds).toEqual([]); 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] }],
});
});
});
}); });

@ -17,6 +17,7 @@ import {
PluginExtensionAddedLinkConfig, PluginExtensionAddedLinkConfig,
urlUtil, urlUtil,
PluginExtensionPoints, PluginExtensionPoints,
ExtensionInfo,
} from '@grafana/data'; } from '@grafana/data';
import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime'; import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { Modal } from '@grafana/ui'; 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. // 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.) // (It is first the plugin that exposes the component, and then the ones that it depends on.)
export const getExposedComponentPluginDependencies = (exposedComponentId: string) => { export const getExposedComponentPluginDependencies = (exposedComponentId: string) => {

Loading…
Cancel
Save