This commit is contained in:
Matt Cowley 2025-10-07 15:24:23 -06:00 committed by GitHub
commit eced7bc4e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 236 additions and 59 deletions

View File

@ -1094,6 +1094,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
*/

View File

@ -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',

View File

@ -1893,6 +1893,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",

View File

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
244 tabularNumbers GA @grafana/grafana-frontend-platform false false false
245 newInfluxDSConfigPageDesign privatePreview @grafana/partner-datasources false false false
246 enableAppChromeExtensions experimental @grafana/plugins-platform-backend false false true
247 enableDashboardEmptyExtensions experimental @grafana/dashboards-squad false false true
248 foldersAppPlatformAPI experimental @grafana/grafana-search-navigate-organise false false true
249 enablePluginImporter experimental @grafana/plugins-platform-backend false false true
250 otelLogsFormatting experimental @grafana/observability-logs false false true

View File

@ -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"

View File

@ -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",

View File

@ -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,

View File

@ -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'),

View File

@ -1,64 +1,33 @@
import { css } from '@emotion/css';
import { useCallback, useEffect } 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 { setInitialDatasource } from 'app/features/dashboard/state/reducers';
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 { useDispatch } from 'app/types/store';
import { setInitialDatasource } from '../state/reducers';
import { DashboardEmptyExtensionPoint } from './DashboardEmptyExtensionPoint';
import {
useIsReadOnlyRepo,
useInitialDatasource,
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 (
<Stack alignItems="center" justifyContent="center">
<div className={styles.wrapper}>
@ -83,7 +52,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}
>
<Trans i18nKey="dashboard.empty.add-visualization-button">Add visualization</Trans>
</Button>
@ -107,7 +76,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}
>
<Trans i18nKey="dashboard.empty.add-library-panel-button">Add library panel</Trans>
</Button>
@ -133,11 +102,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}
>
<Trans i18nKey="dashboard.empty.import-dashboard-button">Import dashboard</Trans>
</Button>
@ -150,6 +116,49 @@ 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 initialDatasource = useInitialDatasource();
const onAddVisualization = useOnAddVisualization({ ...props, isReadOnlyRepo, initialDatasource });
const onAddLibraryPanel = useOnAddLibraryPanel({ ...props, isReadOnlyRepo, initialDatasource });
const onImportDashboard = useOnImportDashboard({ ...props, isReadOnlyRepo, initialDatasource });
// Ensure that if the user leaves the dashboard empty screen, we clear the initial datasource
// We don't want to show UI related to the initial datasource if the user comes back later
const dispatch = useDispatch();
useEffect(() => {
return () => {
dispatch(setInitialDatasource(undefined));
};
}, [dispatch]);
return (
<DashboardEmptyExtensionPoint
renderDefaultUI={useCallback(
() => (
<InternalDashboardEmpty
onAddVisualization={onAddVisualization}
onAddLibraryPanel={onAddLibraryPanel}
onImportDashboard={onImportDashboard}
/>
),
[onAddVisualization, onAddLibraryPanel, onImportDashboard]
)}
initialDatasource={initialDatasource}
onAddVisualization={onAddVisualization}
onAddLibraryPanel={onAddLibraryPanel}
onImportDashboard={onImportDashboard}
/>
);
};
export default DashboardEmpty;
function getStyles(theme: GrafanaTheme2) {

View File

@ -0,0 +1,41 @@
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;
initialDatasource?: string;
onAddVisualization?: () => void;
onAddLibraryPanel?: () => void;
onImportDashboard?: () => void;
}
export function DashboardEmptyExtensionPoint(props: DashboardEmptyExtensionPointProps): JSX.Element | null {
if (config.featureToggles.enableDashboardEmptyExtensions !== true) {
return props.renderDefaultUI();
}
return <InternalDashboardEmptyExtensionPoint {...props} />;
}
// 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<DashboardEmptyExtensionPointProps>({
extensionPointId: PluginExtensionPoints.DashboardEmpty,
});
if (isLoading) {
return <PageLoader />;
}
return (
renderLimitedComponents<DashboardEmptyExtensionPointProps>({
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()
);
}

View File

@ -0,0 +1,90 @@
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;
};
export const useInitialDatasource = () => {
return useSelector((state) => state.dashboard.initialDatasource);
};
interface HookProps extends Props {
isReadOnlyRepo: boolean;
initialDatasource?: string;
}
export const useOnAddVisualization = ({ dashboard, canCreate, isReadOnlyRepo, initialDatasource }: HookProps) => {
const dispatch = useDispatch();
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]);
};

View File

@ -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';