mirror of https://github.com/grafana/grafana.git
Merge cc00a28e3e
into 70dc9a0027
This commit is contained in:
commit
eced7bc4e9
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'),
|
|
@ -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) {
|
|
@ -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()
|
||||
);
|
||||
}
|
|
@ -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]);
|
||||
};
|
|
@ -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';
|
||||
|
|
Loading…
Reference in New Issue