From f27790268254294be7c09832e9a73676fd9a629c Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Thu, 3 Apr 2025 12:16:35 +0200 Subject: [PATCH] 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 --- .../feature-toggles/index.md | 5 +- packages/grafana-data/src/index.ts | 1 + .../src/types/featureToggles.gen.ts | 8 +- packages/grafana-data/src/types/icon.ts | 1 + .../src/themes/GlobalStyles/GlobalStyles.tsx | 5 +- .../src/themes/GlobalStyles/elements.ts | 14 +- pkg/services/featuremgmt/registry.go | 11 +- pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 8 +- pkg/services/featuremgmt/toggles_gen.json | 61 +++-- public/app/AppWrapper.tsx | 29 ++- .../core/components/AppChrome/AppChrome.tsx | 37 ++- .../ExtensionSidebar/ExtensionSidebar.tsx | 64 +++++ .../ExtensionSidebarProvider.test.tsx | 241 ++++++++++++++++++ .../ExtensionSidebarProvider.tsx | 132 ++++++++++ .../ExtensionToolbarItem.test.tsx | 209 +++++++++++++++ .../ExtensionSidebar/ExtensionToolbarItem.tsx | 74 ++++++ .../ExtensionSidebar/GlobalStylesWrapper.tsx | 14 + .../AppChrome/TopBar/SingleTopBar.tsx | 2 + public/app/core/context/GrafanaContext.ts | 7 +- .../auth-config/ProviderConfigForm.test.tsx | 1 + .../plugins/extensions/utils.test.tsx | 120 ++++++++- .../app/features/plugins/extensions/utils.tsx | 41 +++ 23 files changed, 1033 insertions(+), 53 deletions(-) create mode 100644 public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx create mode 100644 public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx create mode 100644 public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx create mode 100644 public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx create mode 100644 public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx create mode 100644 public/app/core/components/AppChrome/ExtensionSidebar/GlobalStylesWrapper.tsx diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index c04e599307d..4e533356d89 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -222,9 +222,10 @@ Experimental features might be changed or removed without prior notice. | `grafanaAdvisor` | Enables Advisor app | | `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing | | `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 | -| `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 | ## Development feature toggles diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index bddb29ba007..ce7a0339f67 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -601,6 +601,7 @@ export { type PluginMetaInfo, type PluginConfigPageProps, type PluginConfigPage, + type ExtensionInfo, } from './types/plugin'; export { type InterpolateFunction, diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f912f764c60..5c6da792edb 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -995,7 +995,7 @@ export interface FeatureToggles { */ grafanaconThemes?: boolean; /** - * Load plugins from CDN synchronously + * Loads plugins from CDN synchronously */ pluginsCDNSyncLoader?: boolean; /** @@ -1058,7 +1058,7 @@ export interface FeatureToggles { */ 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; /** @@ -1066,6 +1066,10 @@ export interface FeatureToggles { */ unifiedStorageGrpcConnectionPool?: boolean; /** + * Enables the extension sidebar + */ + extensionSidebar?: boolean; + /** * Enables the UI functionality to recover and view deleted alert rules * @default true */ diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 9dcdf128257..a9fb6cf08d5 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -256,6 +256,7 @@ export const availableIconsIndex = { 'vertical-align-bottom': true, 'vertical-align-center': true, 'vertical-align-top': true, + 'web-section': true, 'web-section-alt': true, 'wrap-text': true, wrench: true, diff --git a/packages/grafana-ui/src/themes/GlobalStyles/GlobalStyles.tsx b/packages/grafana-ui/src/themes/GlobalStyles/GlobalStyles.tsx index b8f4800fbae..06c6801aae5 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/GlobalStyles.tsx +++ b/packages/grafana-ui/src/themes/GlobalStyles/GlobalStyles.tsx @@ -27,12 +27,13 @@ import { getUtilityClassStyles } from './utilityClasses'; interface GlobalStylesProps { hackNoBackdropBlur?: boolean; + isExtensionSidebarOpen?: boolean; } /** @internal */ export function GlobalStyles(props: GlobalStylesProps) { const theme = useTheme2(); - const { hackNoBackdropBlur } = props; + const { hackNoBackdropBlur, isExtensionSidebarOpen } = props; return ( { }; const MaybeTimeRangeProvider = config.featureToggles.timeRangeProvider ? TimeRangeProvider : Fragment; + const MaybeExtensionSidebarProvider = config.featureToggles.extensionSidebar + ? ExtensionSidebarContextProvider + : Fragment; return ( @@ -121,20 +126,22 @@ export class AppWrapper extends Component { actions={[]} options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }} > - -
- {config.featureToggles.appSidecar ? ( - - ) : ( - - )} - - -
+ + +
+ {config.featureToggles.appSidecar ? ( + + ) : ( + + )} + + +
+
diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index dae29866cb1..a4a436729d4 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -4,7 +4,7 @@ import { PropsWithChildren, useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; 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 { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; import { Trans } from 'app/core/internationalization'; @@ -14,6 +14,8 @@ import { ScopesDashboards } from 'app/features/scopes/dashboards/ScopesDashboard import { AppChromeMenu } from './AppChromeMenu'; 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 { useMegaMenuFocusHelper } from './MegaMenu/utils'; import { ReturnToPrevious } from './ReturnToPrevious/ReturnToPrevious'; @@ -25,10 +27,10 @@ export interface Props extends PropsWithChildren<{}> {} export function AppChrome({ children }: Props) { const { chrome } = useGrafana(); + const { isOpen: isExtensionSidebarOpen, isEnabled: isExtensionSidebarEnabled } = useExtensionSidebarContext(); const state = chrome.useState(); const theme = useTheme2(); const scopes = useScopes(); - const styles = useStyles2(getStyles, Boolean(state.actions) || !!scopes?.state.enabled); const dockedMenuBreakpoint = theme.breakpoints.values.xl; const dockedMenuLocalStorageState = store.getBool(DOCKED_LOCAL_STORAGE_KEY, true); @@ -36,6 +38,7 @@ export function AppChrome({ children }: Props) { const isScopesDashboardsOpen = Boolean( scopes?.state.enabled && scopes?.state.drawerOpened && !scopes?.state.readOnly ); + const styles = useStyles2(getStyles, Boolean(state.actions) || !!scopes?.state.enabled); useMediaQueryChange({ breakpoint: dockedMenuBreakpoint, onChange: (e) => { @@ -52,6 +55,7 @@ export function AppChrome({ children }: Props) { const contentClass = cx({ [styles.content]: true, [styles.contentChromeless]: state.chromeless, + [styles.contentWithSidebar]: isExtensionSidebarOpen && !state.chromeless, }); const handleMegaMenu = () => { @@ -106,7 +110,7 @@ export function AppChrome({ children }: Props) { )}
-
+
{!state.chromeless && (
{children} + {!state.chromeless && isExtensionSidebarEnabled && isExtensionSidebarOpen && ( +
+ +
+ )}
{!state.chromeless && !state.megaMenuDocked && } @@ -145,6 +155,10 @@ const getStyles = (theme: GrafanaTheme2, hasActions: boolean) => { flexGrow: 1, height: 'auto', }), + contentWithSidebar: css({ + height: '100vh', + overflow: 'hidden', + }), contentChromeless: css({ paddingTop: 0, }), @@ -188,6 +202,11 @@ const getStyles = (theme: GrafanaTheme2, hasActions: boolean) => { flexGrow: 1, label: 'page-panes', }), + panesWithSidebar: css({ + height: '100%', + overflow: 'hidden', + position: 'relative', + }), pageContainerMenuDocked: css({ paddingLeft: MENU_WIDTH, }), @@ -200,6 +219,12 @@ const getStyles = (theme: GrafanaTheme2, hasActions: boolean) => { flexDirection: 'column', flexGrow: 1, }), + pageContainerWithSidebar: css({ + overflow: 'auto', + height: '100%', + minHeight: 0, + maxWidth: `calc(100% - ${EXTENSION_SIDEBAR_WIDTH})`, + }), skipLink: css({ position: 'fixed', top: -1000, @@ -210,5 +235,11 @@ const getStyles = (theme: GrafanaTheme2, hasActions: boolean) => { zIndex: theme.zIndex.portal, }, }), + sidebarContainer: css({ + position: 'fixed', + height: `calc(100% - ${TOP_BAR_LEVEL_HEIGHT}px)`, + zIndex: 2, + right: 0, + }), }; }; diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx new file mode 100644 index 00000000000..d7ea37c0186 --- /dev/null +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx @@ -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 ( +
+
+ +
+
+ ); +} + +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', + }), + }; +}; diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx new file mode 100644 index 00000000000..2055802bf22 --- /dev/null +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx @@ -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 ( +
+
{context.isOpen.toString()}
+
{context.dockedComponentId || 'undefined'}
+
{context.availableComponents.size}
+
{Array.from(context.availableComponents.keys()).join(', ')}
+
{context.isEnabled.toString()}
+
+ ); + }; + + it('should provide default context values', () => { + render( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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 ( +
+ + +
+ ); + }; + + render( + + + + ); + + 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( + + + + ); + + // 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(); + }); + }); +}); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx new file mode 100644 index 00000000000..c3a71082603 --- /dev/null +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx @@ -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({ + 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(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 ( + + {children} + + ); +}; + +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; + } +} diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx new file mode 100644 index 00000000000..ccea31d0059 --- /dev/null +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx @@ -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 ( +
+
{isOpen.toString()}
+
{dockedComponentId}
+
+ ); +}; + +const setup = () => { + return render( + + + + + ); +}; + +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'); + }); +}); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx new file mode 100644 index 00000000000..c56eadad7a7 --- /dev/null +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx @@ -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 ( + { + if (isOpen) { + setDockedComponentId(undefined); + } else { + setDockedComponentId(getComponentIdFromComponentMeta(components[0].pluginId, components[0])); + } + }} + /> + ); + } + + const MenuItems = ( + + {components.map((c) => { + const id = getComponentIdFromComponentMeta(c.pluginId, c); + return ( + { + if (isOpen && dockedComponentId === id) { + setDockedComponentId(undefined); + } else { + setDockedComponentId(id); + } + }} + /> + ); + })} + + ); + return ( + + + + ); +} diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/GlobalStylesWrapper.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/GlobalStylesWrapper.tsx new file mode 100644 index 00000000000..dfc71acc4f3 --- /dev/null +++ b/public/app/core/components/AppChrome/ExtensionSidebar/GlobalStylesWrapper.tsx @@ -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 ; +}; diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index 01ba7281c20..c3319f41975 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -15,6 +15,7 @@ import { useSelector } from 'app/types'; import { Branding } from '../../Branding/Branding'; import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; import { buildBreadcrumbs } from '../../Breadcrumbs/utils'; +import { ExtensionToolbarItem } from '../ExtensionSidebar/ExtensionToolbarItem'; import { HistoryContainer } from '../History/HistoryContainer'; import { enrichHelpItem } from '../MegaMenu/utils'; import { QuickAdd } from '../QuickAdd/QuickAdd'; @@ -88,6 +89,7 @@ export const SingleTopBar = memo(function SingleTopBar({ /> {!contextSrv.user.isSignedIn && } {config.featureToggles.inviteUserExperimental && } + {config.featureToggles.extensionSidebar && } {profileNode && }
diff --git a/public/app/core/context/GrafanaContext.ts b/public/app/core/context/GrafanaContext.ts index 1a82d7f5c3d..8ce1dcacd0e 100644 --- a/public/app/core/context/GrafanaContext.ts +++ b/public/app/core/context/GrafanaContext.ts @@ -1,10 +1,12 @@ import { createContext, useCallback, useContext } from 'react'; import { useObservable } from 'react-use'; +import { of } from 'rxjs'; import { GrafanaConfig } from '@grafana/data'; import { LocationService, locationService, BackendSrv } from '@grafana/runtime'; import { AppChromeService } from '../components/AppChrome/AppChromeService'; +import { useExtensionSidebarContext } from '../components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider'; import { NewFrontendAssetsChecker } from '../services/NewFrontendAssetsChecker'; import { KeybindingSrv } from '../services/keybindingSrv'; @@ -45,5 +47,8 @@ export function useReturnToPreviousInternal() { export function useChromeHeaderHeight() { 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); } diff --git a/public/app/features/auth-config/ProviderConfigForm.test.tsx b/public/app/features/auth-config/ProviderConfigForm.test.tsx index 469766c29f3..310684532b3 100644 --- a/public/app/features/auth-config/ProviderConfigForm.test.tsx +++ b/public/app/features/auth-config/ProviderConfigForm.test.tsx @@ -17,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({ delete: deleteMock, }), config: { + ...jest.requireActual('@grafana/runtime').config, panels: { test: { id: 'test', diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index 0e2d98f44b3..3075569aaba 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -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] }], + }); + }); + }); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index ff7157c46b5..ff452777688 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -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 => 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) => {