diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 9440b7b1187..b879716fb13 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1093,6 +1093,11 @@ export interface FeatureToggles { */ enableAppChromeExtensions?: boolean; /** + * Set this to true to enable all dashboard empty state extensions registered by plugins. + * @default false + */ + enableDashboardEmptyExtensions?: boolean; + /** * Enables use of app platform API for folders * @default false */ diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 4707c9123dd..9a02e5fac27 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -195,6 +195,7 @@ export enum PluginExtensionPoints { AlertingRuleQueryEditor = 'grafana/alerting/alertingrule/queryeditor', CommandPalette = 'grafana/commandpalette/action', DashboardPanelMenu = 'grafana/dashboard/panel/menu', + DashboardEmpty = 'grafana/dashboard/empty', DataSourceConfig = 'grafana/datasources/config', DataSourceConfigActions = 'grafana/datasources/config/actions', DataSourceConfigErrorStatus = 'grafana/datasources/config/error-status', diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index cdd14fc665e..2ca2f9c6e63 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1891,6 +1891,16 @@ var ( FrontendOnly: true, Expression: "false", // extensions will be disabled by default }, + { + Name: "enableDashboardEmptyExtensions", + Description: "Set this to true to enable all dashboard empty state extensions registered by plugins.", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + HideFromAdminPage: true, + HideFromDocs: true, + FrontendOnly: true, + Expression: "false", // extensions will be disabled by default + }, { Name: "foldersAppPlatformAPI", Description: "Enables use of app platform API for folders", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 2a20f2e8a0b..0155877dfcb 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -244,6 +244,7 @@ preferLibraryPanelTitle,privatePreview,@grafana/dashboards-squad,false,false,fal tabularNumbers,GA,@grafana/grafana-frontend-platform,false,false,false newInfluxDSConfigPageDesign,privatePreview,@grafana/partner-datasources,false,false,false enableAppChromeExtensions,experimental,@grafana/plugins-platform-backend,false,false,true +enableDashboardEmptyExtensions,experimental,@grafana/dashboards-squad,false,false,true foldersAppPlatformAPI,experimental,@grafana/grafana-search-navigate-organise,false,false,true enablePluginImporter,experimental,@grafana/plugins-platform-backend,false,false,true otelLogsFormatting,experimental,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index ea44f2f188c..b4d70df2eb3 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -986,6 +986,10 @@ const ( // Set this to true to enable all app chrome extensions registered by plugins. FlagEnableAppChromeExtensions = "enableAppChromeExtensions" + // FlagEnableDashboardEmptyExtensions + // Set this to true to enable all dashboard empty state extensions registered by plugins. + FlagEnableDashboardEmptyExtensions = "enableDashboardEmptyExtensions" + // FlagFoldersAppPlatformAPI // Enables use of app platform API for folders FlagFoldersAppPlatformAPI = "foldersAppPlatformAPI" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 003da45e0f8..e76de582bc9 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1383,6 +1383,22 @@ "expression": "false" } }, + { + "metadata": { + "name": "enableDashboardEmptyExtensions", + "resourceVersion": "1759194774156", + "creationTimestamp": "2025-09-30T01:12:54Z" + }, + "spec": { + "description": "Set this to true to enable all dashboard empty state extensions registered by plugins.", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "enableDatagridEditing", diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 886dd0a9391..8c62ed47db0 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -20,7 +20,7 @@ import { import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { useStyles2 } from '@grafana/ui'; import { GRID_COLUMN_COUNT } from 'app/core/constants'; -import DashboardEmpty from 'app/features/dashboard/dashgrid/DashboardEmpty'; +import DashboardEmpty from 'app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty'; import { dashboardEditActions, diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx similarity index 96% rename from public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx rename to public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx index 06f1438646d..e4f9839fdfa 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx @@ -3,10 +3,10 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { locationService, reportInteraction } from '@grafana/runtime'; import { defaultDashboard } from '@grafana/schema'; -import { createDashboardModelFixture } from '../state/__fixtures__/dashboardFixtures'; -import { onCreateNewPanel, onImportDashboard, onAddLibraryPanel } from '../utils/dashboard'; +import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; +import { onCreateNewPanel, onImportDashboard, onAddLibraryPanel } from '../../utils/dashboard'; -import DashboardEmpty, { Props } from './DashboardEmpty'; +import DashboardEmpty, { type Props } from './DashboardEmpty'; jest.mock('app/types/store', () => ({ ...jest.requireActual('app/types/store'), diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx similarity index 67% rename from public/app/features/dashboard/dashgrid/DashboardEmpty.tsx rename to public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx index 8f16c675ede..06117bb795c 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx @@ -1,64 +1,30 @@ import { css } from '@emotion/css'; +import { useCallback } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans } from '@grafana/i18n'; -import { locationService } from '@grafana/runtime'; import { Button, useStyles2, Text, Box, Stack, TextLink } from '@grafana/ui'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; -import { - onAddLibraryPanel as onAddLibraryPanelImpl, - onCreateNewPanel, - onImportDashboard, -} from 'app/features/dashboard/utils/dashboard'; -import { buildPanelEditScene } from 'app/features/dashboard-scene/panel-edit/PanelEditor'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; -import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; -import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; -import { useDispatch, useSelector } from 'app/types/store'; -import { setInitialDatasource } from '../state/reducers'; +import { DashboardEmptyExtensionPoint } from './DashboardEmptyExtensionPoint'; +import { + useIsReadOnlyRepo, + useOnAddVisualization, + useOnAddLibraryPanel, + useOnImportDashboard, +} from './DashboardEmptyHooks'; -export interface Props { - dashboard: DashboardModel | DashboardScene; - canCreate: boolean; +interface InternalProps { + onAddVisualization?: () => void; + onAddLibraryPanel?: () => void; + onImportDashboard?: () => void; } -const DashboardEmpty = ({ dashboard, canCreate }: Props) => { +const InternalDashboardEmpty = ({ onAddVisualization, onAddLibraryPanel, onImportDashboard }: InternalProps) => { const styles = useStyles2(getStyles); - const dispatch = useDispatch(); - const initialDatasource = useSelector((state) => state.dashboard.initialDatasource); - // Get repository information to check if it's read-only - const { isReadOnlyRepo } = useGetResourceRepositoryView({ - folderName: dashboard instanceof DashboardScene ? dashboard.state.meta.folderUid : dashboard.meta.folderUid, - }); - - const onAddVisualization = () => { - let id; - if (dashboard instanceof DashboardScene) { - const panel = dashboard.onCreateNewPanel(); - dashboard.setState({ editPanel: buildPanelEditScene(panel, true) }); - locationService.partial({ firstPanel: true }); - } else { - id = onCreateNewPanel(dashboard, initialDatasource); - dispatch(setInitialDatasource(undefined)); - locationService.partial({ editPanel: id, firstPanel: true }); - } - - DashboardInteractions.emptyDashboardButtonClicked({ item: 'add_visualization' }); - }; - - const onAddLibraryPanel = () => { - DashboardInteractions.emptyDashboardButtonClicked({ item: 'import_from_library' }); - if (dashboard instanceof DashboardScene) { - dashboard.onShowAddLibraryPanelDrawer(); - } else { - onAddLibraryPanelImpl(dashboard); - } - }; - - const isProvisioned = dashboard instanceof DashboardScene && dashboard.isManagedRepository(); return (
@@ -83,7 +49,7 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { icon="plus" data-testid={selectors.pages.AddDashboard.itemButton('Create new panel button')} onClick={onAddVisualization} - disabled={!canCreate || isReadOnlyRepo} + disabled={!onAddVisualization} > Add visualization @@ -107,7 +73,7 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { fill="outline" data-testid={selectors.pages.AddDashboard.itemButton('Add a panel from the panel library button')} onClick={onAddLibraryPanel} - disabled={!canCreate || isProvisioned || isReadOnlyRepo} + disabled={!onAddLibraryPanel} > Add library panel @@ -133,11 +99,8 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { icon="upload" fill="outline" data-testid={selectors.pages.AddDashboard.itemButton('Import dashboard button')} - onClick={() => { - DashboardInteractions.emptyDashboardButtonClicked({ item: 'import_dashboard' }); - onImportDashboard(); - }} - disabled={!canCreate || isReadOnlyRepo} + onClick={onImportDashboard} + disabled={!onImportDashboard} > Import dashboard @@ -150,6 +113,38 @@ const DashboardEmpty = ({ dashboard, canCreate }: Props) => { ); }; +export interface Props { + dashboard: DashboardModel | DashboardScene; + canCreate: boolean; +} + +// We pass the default empty UI through to the extension point so that the extension can conditionally render it if needed. +// For example, an extension might want to render custom UI for a specific experiment cohort, and the default UI for everyone else. +const DashboardEmpty = (props: Props) => { + const isReadOnlyRepo = useIsReadOnlyRepo(props); + const onAddVisualization = useOnAddVisualization({ ...props, isReadOnlyRepo }); + const onAddLibraryPanel = useOnAddLibraryPanel({ ...props, isReadOnlyRepo }); + const onImportDashboard = useOnImportDashboard({ ...props, isReadOnlyRepo }); + + return ( + ( + + ), + [onAddVisualization, onAddLibraryPanel, onImportDashboard] + )} + onAddVisualization={onAddVisualization} + onAddLibraryPanel={onAddLibraryPanel} + onImportDashboard={onImportDashboard} + /> + ); +}; + export default DashboardEmpty; function getStyles(theme: GrafanaTheme2) { diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmptyExtensionPoint.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmptyExtensionPoint.tsx new file mode 100644 index 00000000000..5e7a551514f --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmptyExtensionPoint.tsx @@ -0,0 +1,40 @@ +import { PluginExtensionPoints } from '@grafana/data'; +import { config, renderLimitedComponents, usePluginComponents } from '@grafana/runtime'; +import PageLoader from 'app/core/components/PageLoader/PageLoader'; + +interface DashboardEmptyExtensionPointProps { + renderDefaultUI: () => JSX.Element; + onAddVisualization?: () => void; + onAddLibraryPanel?: () => void; + onImportDashboard?: () => void; +} + +export function DashboardEmptyExtensionPoint(props: DashboardEmptyExtensionPointProps): JSX.Element | null { + if (config.featureToggles.enableDashboardEmptyExtensions !== true) { + return props.renderDefaultUI(); + } + + return ; +} + +// We have this "internal" component so we can prevent pre-loading the plugins associated with the extension-point if the feature is not enabled. +function InternalDashboardEmptyExtensionPoint(props: DashboardEmptyExtensionPointProps): JSX.Element | null { + const { components, isLoading } = usePluginComponents({ + extensionPointId: PluginExtensionPoints.DashboardEmpty, + }); + + if (isLoading) { + return ; + } + + return ( + renderLimitedComponents({ + props, + components: components, + // We only ever want one component to replace the default empty state UI (so that we don't end up with two competing/default UIs being rendered). + // And, currently, we only want to allow setupguide-app to be able to do this. + limit: 1, + pluginId: 'grafana-setupguide-app', + }) ?? props.renderDefaultUI() + ); +} diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmptyHooks.ts b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmptyHooks.ts new file mode 100644 index 00000000000..7340226a7a2 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmptyHooks.ts @@ -0,0 +1,86 @@ +import { useMemo } from 'react'; + +import { locationService } from '@grafana/runtime'; +import { buildPanelEditScene } from 'app/features/dashboard-scene/panel-edit/PanelEditor'; +import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; +import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions'; +import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; +import { useDispatch, useSelector } from 'app/types/store'; + +import { setInitialDatasource } from '../../state/reducers'; +import { + onCreateNewPanel, + onAddLibraryPanel as onAddLibraryPanelImpl, + onImportDashboard as onImportDashboardImpl, +} from '../../utils/dashboard'; + +import type { Props } from './DashboardEmpty'; + +export const useIsReadOnlyRepo = ({ dashboard }: Props) => { + const { isReadOnlyRepo } = useGetResourceRepositoryView({ + folderName: dashboard instanceof DashboardScene ? dashboard.state.meta.folderUid : dashboard.meta.folderUid, + }); + + return isReadOnlyRepo; +}; + +interface HookProps extends Props { + isReadOnlyRepo: boolean; +} + +export const useOnAddVisualization = ({ dashboard, canCreate, isReadOnlyRepo }: HookProps) => { + const dispatch = useDispatch(); + const initialDatasource = useSelector((state) => state.dashboard.initialDatasource); + + return useMemo(() => { + if (!canCreate || isReadOnlyRepo) { + return undefined; + } + + return () => { + if (dashboard instanceof DashboardScene) { + const panel = dashboard.onCreateNewPanel(); + dashboard.setState({ editPanel: buildPanelEditScene(panel, true) }); + locationService.partial({ firstPanel: true }); + } else { + const id = onCreateNewPanel(dashboard, initialDatasource); + dispatch(setInitialDatasource(undefined)); + locationService.partial({ editPanel: id, firstPanel: true }); + } + + DashboardInteractions.emptyDashboardButtonClicked({ item: 'add_visualization' }); + }; + }, [canCreate, isReadOnlyRepo, dashboard, dispatch, initialDatasource]); +}; + +export const useOnAddLibraryPanel = ({ dashboard, canCreate, isReadOnlyRepo }: HookProps) => { + const isProvisioned = dashboard instanceof DashboardScene && dashboard.isManagedRepository(); + + return useMemo(() => { + if (!canCreate || isProvisioned || isReadOnlyRepo) { + return undefined; + } + + return () => { + DashboardInteractions.emptyDashboardButtonClicked({ item: 'import_from_library' }); + if (dashboard instanceof DashboardScene) { + dashboard.onShowAddLibraryPanelDrawer(); + } else { + onAddLibraryPanelImpl(dashboard); + } + }; + }, [canCreate, isProvisioned, isReadOnlyRepo, dashboard]); +}; + +export const useOnImportDashboard = ({ dashboard, canCreate, isReadOnlyRepo }: HookProps) => { + return useMemo(() => { + if (!canCreate || isReadOnlyRepo) { + return undefined; + } + + return () => { + DashboardInteractions.emptyDashboardButtonClicked({ item: 'import_dashboard' }); + onImportDashboardImpl(); + }; + }, [canCreate, isReadOnlyRepo]); +}; diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index a8cd9720e22..534d5ad64ac 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -16,7 +16,7 @@ import { DashboardRow } from '../components/DashboardRow'; import { DashboardModel } from '../state/DashboardModel'; import { GridPos, PanelModel } from '../state/PanelModel'; -import DashboardEmpty from './DashboardEmpty'; +import DashboardEmpty from './DashboardEmpty/DashboardEmpty'; import { DashboardPanel } from './DashboardPanel'; export const PANEL_FILTER_VARIABLE = 'systemPanelFilterVar';