Sidebar: Create a sidebar that can render an extension (#102626)
* Extension Sidebar: Add missing `web-section` icon * Extension Sidebar: Add core extension sidebar components * Extension Sidebar: Integrate extension sidebar into Grafana * Extension Sidebar: Change extension point to alpha * Extension Sidebar: Fix saved state of docked extensions * Extension Sidebar: Delete from local storage if undocked * Extension Sidebar: Move main scrollbar from body to pane * Extension Sidebar: Expose `ExtensionInfo` * Extension Sidebar: Move `useComponents` into ExtensionSidebar * Extension Sidebar: Store selection in `localStorage` * Extension Sidebar: Simplify return of extension point meta * Extension Sidebar: Ensure `body` is scrollable when sidebar is closed * Extension Sidebar: Add missing `GlobalStyles` change * Extension Sidebar: Don't render `ExtensionSidebar` unless it should be open * Extension Sidebar: Better toggle handling * Extension Sidebar: Fix wrong header height * Extension Sidebar: Change `getExtensionPointPluginMeta` to use `addedComponents` and add documentation * Extension Sidebar: Add tests for `getExtensionPointPluginMeta` * Extension Sidebar: Add tests for `ExtensionSidebarProvider` * Extension Sidebar: Fix tests `ExtensionSidebarProvider` * Extension Sidebar: Add tests `ExtensionToolbarItem` * Extension Sidebar: Add `extensionSidebar` feature toggle * Extension Sidebar: Put implementation behind `extensionSidebar` feature toggle * update feature toggles * fix lint * Extension Sidebar: Only toggle if clicking the same button * Extension Sidebar: Hide sidebar if chromeless * Update feature toggles doc * Sidebar: Add `isEnabled` to ExtensionSidebarProvider * Extension Sidebar: Use conditional CSS classes * Extension Sidebar: Move header height to GrafanaContext * Sidebar: Adapt to feature toggle change * Sidebar: Remove unused import * Sidebar: Keep featuretoggles in ExtensionSidebar tests * ProviderConfig: Keep `config` import in tests * FeatureToggles: adapt to docs review * fix typo
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -601,6 +601,7 @@ export {
|
||||
type PluginMetaInfo,
|
||||
type PluginConfigPageProps,
|
||||
type PluginConfigPage,
|
||||
type ExtensionInfo,
|
||||
} from './types/plugin';
|
||||
export {
|
||||
type InterpolateFunction,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<Global
|
||||
@@ -43,7 +44,7 @@ export function GlobalStyles(props: GlobalStylesProps) {
|
||||
getCodeStyles(theme),
|
||||
getDashDiffStyles(theme),
|
||||
getDashboardGridStyles(theme),
|
||||
getElementStyles(theme),
|
||||
getElementStyles(theme, isExtensionSidebarOpen),
|
||||
getExtraStyles(theme),
|
||||
getFilterTableStyles(theme),
|
||||
getFontStyles(theme),
|
||||
|
||||
@@ -4,7 +4,13 @@ import { GrafanaTheme2, ThemeTypographyVariant } from '@grafana/data';
|
||||
|
||||
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({
|
||||
'*, *::before, *::after': {
|
||||
boxSizing: 'inherit',
|
||||
@@ -40,11 +46,6 @@ export function getElementStyles(theme: GrafanaTheme2) {
|
||||
position: 'unset',
|
||||
color: theme.colors.text.primary,
|
||||
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',
|
||||
'@media print': {
|
||||
overflow: 'visible',
|
||||
@@ -59,6 +60,7 @@ export function getElementStyles(theme: GrafanaTheme2) {
|
||||
// see https://github.com/rsms/inter/issues/222
|
||||
fontVariantLigatures: 'no-contextual',
|
||||
...theme.typography.body,
|
||||
...bodyOverflow,
|
||||
},
|
||||
|
||||
'h1, .h1': getVariantStyles(theme.typography.h1),
|
||||
|
||||
@@ -1698,7 +1698,7 @@ var (
|
||||
},
|
||||
{
|
||||
Name: "pluginsCDNSyncLoader",
|
||||
Description: "Load plugins from CDN synchronously",
|
||||
Description: "Loads plugins from CDN synchronously",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaPluginsPlatformSquad,
|
||||
},
|
||||
@@ -1819,7 +1819,7 @@ var (
|
||||
},
|
||||
{
|
||||
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,
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
},
|
||||
@@ -1831,6 +1831,13 @@ var (
|
||||
HideFromAdminPage: true,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "extensionSidebar",
|
||||
Description: "Enables the extension sidebar",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
{
|
||||
Name: "alertingRuleRecoverDeleted",
|
||||
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
|
||||
localeFormatPreference,experimental,@grafana/grafana-frontend-platform,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
|
||||
xrayApplicationSignals,experimental,@grafana/aws-datasources,false,false,true
|
||||
multiTenantTempCredentials,experimental,@grafana/aws-datasources,false,false,false
|
||||
|
||||
|
@@ -908,7 +908,7 @@ const (
|
||||
FlagGrafanaconThemes = "grafanaconThemes"
|
||||
|
||||
// FlagPluginsCDNSyncLoader
|
||||
// Load plugins from CDN synchronously
|
||||
// Loads plugins from CDN synchronously
|
||||
FlagPluginsCDNSyncLoader = "pluginsCDNSyncLoader"
|
||||
|
||||
// FlagAlertingJiraIntegration
|
||||
@@ -968,13 +968,17 @@ const (
|
||||
FlagAzureMonitorLogsBuilderEditor = "azureMonitorLogsBuilderEditor"
|
||||
|
||||
// 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"
|
||||
|
||||
// FlagUnifiedStorageGrpcConnectionPool
|
||||
// Enables the unified storage grpc connection pool
|
||||
FlagUnifiedStorageGrpcConnectionPool = "unifiedStorageGrpcConnectionPool"
|
||||
|
||||
// FlagExtensionSidebar
|
||||
// Enables the extension sidebar
|
||||
FlagExtensionSidebar = "extensionSidebar"
|
||||
|
||||
// FlagAlertingRuleRecoverDeleted
|
||||
// Enables the UI functionality to recover and view deleted alert rules
|
||||
FlagAlertingRuleRecoverDeleted = "alertingRuleRecoverDeleted"
|
||||
|
||||
@@ -437,21 +437,6 @@
|
||||
"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": {
|
||||
"name": "alertingRuleRecoverDeleted",
|
||||
@@ -471,6 +456,21 @@
|
||||
"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": {
|
||||
"name": "alertingRuleVersionHistoryRestore",
|
||||
@@ -1739,6 +1739,19 @@
|
||||
"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": {
|
||||
"name": "externalCorePlugins",
|
||||
@@ -2496,11 +2509,14 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "localeFormatPreference",
|
||||
"resourceVersion": "1742397804851",
|
||||
"creationTimestamp": "2025-03-19T15:23:24Z"
|
||||
"resourceVersion": "1743662997585",
|
||||
"creationTimestamp": "2025-03-19T15:23:24Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-04-03 06:49:57.585909 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"codeowner": "@grafana/grafana-frontend-platform"
|
||||
}
|
||||
@@ -3302,11 +3318,14 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "pluginsCDNSyncLoader",
|
||||
"resourceVersion": "1737026684018",
|
||||
"creationTimestamp": "2025-01-16T11:24:44Z"
|
||||
"resourceVersion": "1743664300735",
|
||||
"creationTimestamp": "2025-01-16T11:24:44Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-04-03 07:11:40.735562 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Load plugins from CDN synchronously",
|
||||
"description": "Loads plugins from CDN synchronously",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/plugins-platform-backend"
|
||||
}
|
||||
|
||||
+18
-11
@@ -11,12 +11,14 @@ import {
|
||||
SidecarContext_EXPERIMENTAL,
|
||||
sidecarServiceSingleton_EXPERIMENTAL,
|
||||
} 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 { store } from 'app/store/store';
|
||||
|
||||
import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled';
|
||||
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 { GrafanaRouteWrapper } from './core/navigation/GrafanaRoute';
|
||||
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 MaybeExtensionSidebarProvider = config.featureToggles.extensionSidebar
|
||||
? ExtensionSidebarContextProvider
|
||||
: Fragment;
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
@@ -121,20 +126,22 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
|
||||
actions={[]}
|
||||
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
|
||||
>
|
||||
<GlobalStyles hackNoBackdropBlur={config.featureToggles.noBackdropBlur} />
|
||||
<MaybeTimeRangeProvider>
|
||||
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
|
||||
<ScopesContextProvider>
|
||||
<ExtensionRegistriesProvider registries={pluginExtensionRegistries}>
|
||||
<div className="grafana-app">
|
||||
{config.featureToggles.appSidecar ? (
|
||||
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
|
||||
) : (
|
||||
<RouterWrapper {...routerWrapperProps} />
|
||||
)}
|
||||
<LiveConnectionWarning />
|
||||
<PortalContainer />
|
||||
</div>
|
||||
<MaybeExtensionSidebarProvider>
|
||||
<GlobalStylesWrapper />
|
||||
<div className="grafana-app">
|
||||
{config.featureToggles.appSidecar ? (
|
||||
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
|
||||
) : (
|
||||
<RouterWrapper {...routerWrapperProps} />
|
||||
)}
|
||||
<LiveConnectionWarning />
|
||||
<PortalContainer />
|
||||
</div>
|
||||
</MaybeExtensionSidebarProvider>
|
||||
</ExtensionRegistriesProvider>
|
||||
</ScopesContextProvider>
|
||||
</SidecarContext_EXPERIMENTAL.Provider>
|
||||
|
||||
@@ -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) {
|
||||
</>
|
||||
)}
|
||||
<div className={contentClass}>
|
||||
<div className={styles.panes}>
|
||||
<div className={cx(styles.panes, { [styles.panesWithSidebar]: isExtensionSidebarOpen })}>
|
||||
{!state.chromeless && (
|
||||
<div
|
||||
className={cx(styles.scopesDashboardsContainer, {
|
||||
@@ -120,11 +124,17 @@ export function AppChrome({ children }: Props) {
|
||||
className={cx(styles.pageContainer, {
|
||||
[styles.pageContainerMenuDocked]: menuDockedAndOpen || isScopesDashboardsOpen,
|
||||
[styles.pageContainerMenuDockedScopes]: menuDockedAndOpen && isScopesDashboardsOpen,
|
||||
[styles.pageContainerWithSidebar]: !state.chromeless && isExtensionSidebarOpen,
|
||||
})}
|
||||
id="pageContent"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{!state.chromeless && isExtensionSidebarEnabled && isExtensionSidebarOpen && (
|
||||
<div className={styles.sidebarContainer}>
|
||||
<ExtensionSidebar />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!state.chromeless && !state.megaMenuDocked && <AppChromeMenu />}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
+241
@@ -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 { 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 && <SignInLink />}
|
||||
{config.featureToggles.inviteUserExperimental && <InviteUserButton />}
|
||||
{config.featureToggles.extensionSidebar && <ExtensionToolbarItem />}
|
||||
{profileNode && <ProfileButton profileNode={profileNode} />}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
delete: deleteMock,
|
||||
}),
|
||||
config: {
|
||||
...jest.requireActual('@grafana/runtime').config,
|
||||
panels: {
|
||||
test: {
|
||||
id: 'test',
|
||||
|
||||
@@ -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] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PluginExtensionAddedLinkConfig,
|
||||
urlUtil,
|
||||
PluginExtensionPoints,
|
||||
ExtensionInfo,
|
||||
} from '@grafana/data';
|
||||
import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
|
||||
import { Modal } from '@grafana/ui';
|
||||
@@ -446,6 +447,46 @@ export const getExtensionPointPluginDependencies = (extensionPointId: string): s
|
||||
}, []);
|
||||
};
|
||||
|
||||
export type ExtensionPointPluginMeta = Map<
|
||||
string,
|
||||
{
|
||||
readonly addedComponents: ExtensionInfo[];
|
||||
readonly addedLinks: ExtensionInfo[];
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Returns a map of plugin ids and their addedComponents and addedLinks to the extension point.
|
||||
* @param extensionPointId - The id of the extension point.
|
||||
* @returns A map of plugin ids and their addedComponents and addedLinks to the extension point.
|
||||
*/
|
||||
export const getExtensionPointPluginMeta = (extensionPointId: string): ExtensionPointPluginMeta => {
|
||||
return new Map(
|
||||
getExtensionPointPluginDependencies(extensionPointId)
|
||||
.map((pluginId) => {
|
||||
const app = config.apps[pluginId];
|
||||
// if the plugin does not exist or does not expose any components or links to the extension point, return undefined
|
||||
if (
|
||||
!app ||
|
||||
(!app.extensions.addedComponents.some((component) => component.targets.includes(extensionPointId)) &&
|
||||
!app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
pluginId,
|
||||
{
|
||||
addedComponents: app.extensions.addedComponents.filter((component) =>
|
||||
component.targets.includes(extensionPointId)
|
||||
),
|
||||
addedLinks: app.extensions.addedLinks.filter((link) => link.targets.includes(extensionPointId)),
|
||||
},
|
||||
] as const;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== undefined)
|
||||
);
|
||||
};
|
||||
|
||||
// Returns a list of app plugin ids that are necessary to be loaded to use the exposed component.
|
||||
// (It is first the plugin that exposes the component, and then the ones that it depends on.)
|
||||
export const getExposedComponentPluginDependencies = (exposedComponentId: string) => {
|
||||
|
||||
Reference in New Issue
Block a user