diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index b767026c7a2..c530b03a6aa 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -567,6 +567,8 @@ export { type PluginExtensionPanelContext, type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context, type PluginExtensionDataSourceConfigContext, + type PluginExtensionDataSourceConfigActionsContext, + type PluginExtensionDataSourceConfigStatusContext, type PluginExtensionCommandPaletteContext, type PluginExtensionOpenModalOptions, type PluginExtensionExposedComponentConfig, diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 3ed7de0c766..788fd7b3135 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -194,7 +194,9 @@ export enum PluginExtensionPoints { CommandPalette = 'grafana/commandpalette/action', DashboardPanelMenu = 'grafana/dashboard/panel/menu', DataSourceConfig = 'grafana/datasources/config', + DataSourceConfigActions = 'grafana/datasources/config/actions', DataSourceConfigErrorStatus = 'grafana/datasources/config/error-status', + DataSourceConfigStatus = 'grafana/datasources/config/status', ExploreToolbarAction = 'grafana/explore/toolbar/action', UserProfileTab = 'grafana/user/profile/tab', TraceViewDetails = 'grafana/traceview/details', @@ -271,6 +273,30 @@ export type DataSourceConfigErrorStatusContext = { }; }; +export type PluginExtensionDataSourceConfigActionsContext = { + dataSource: { + type: string; + uid: string; + name: string; + typeName: string; + }; +}; + +export type PluginExtensionDataSourceConfigStatusContext = { + dataSource: { + type: string; + uid: string; + name: string; + typeName: string; + }; + testingStatus?: { + message?: string | null; + status?: string | null; + details?: Record; + }; + severity: 'success' | 'error' | 'warning' | 'info'; +}; + type Dashboard = { uid: string; title: string; diff --git a/public/app/features/datasources/components/DataSourceTestingStatus.test.tsx b/public/app/features/datasources/components/DataSourceTestingStatus.test.tsx index dec9658d72f..8308d8ed84c 100644 --- a/public/app/features/datasources/components/DataSourceTestingStatus.test.tsx +++ b/public/app/features/datasources/components/DataSourceTestingStatus.test.tsx @@ -1,12 +1,19 @@ import { render, screen, fireEvent } from '@testing-library/react'; -import { PluginExtensionTypes } from '@grafana/data'; -import { setPluginLinksHook } from '@grafana/runtime'; +import { PluginExtensionTypes, PluginExtensionLink } from '@grafana/data'; +import { setPluginLinksHook, UsePluginLinksOptions } from '@grafana/runtime'; import { getMockDataSource } from '../mocks/dataSourcesMocks'; import { DataSourceTestingStatus, Props } from './DataSourceTestingStatus'; +// Mock contextSrv +jest.mock('../../../core/core', () => ({ + contextSrv: { + hasAccessToExplore: jest.fn(() => true), + }, +})); + setPluginLinksHook(() => ({ links: [], isLoading: false })); const getProps = (partialProps?: Partial): Props => ({ @@ -34,8 +41,8 @@ describe('', () => { render(); expect(screen.getByText('Data source is definitely working')).toBeInTheDocument(); - expect(screen.getByTestId('data-testid Alert success')).toBeInTheDocument(); - expect(() => screen.getByTestId('data-testid Alert error')).toThrow(); + expect(screen.getByTestId('data-testid Data source settings page Alert')).toBeInTheDocument(); + expect(screen.queryByTestId('data-testid Alert error')).not.toBeInTheDocument(); }); it('should render successful message when testing status is uppercase "OK"', () => { @@ -48,8 +55,8 @@ describe('', () => { render(); expect(screen.getByText('Data source is definitely working')).toBeInTheDocument(); - expect(screen.getByTestId('data-testid Alert success')).toBeInTheDocument(); - expect(() => screen.getByTestId('data-testid Alert error')).toThrow(); + expect(screen.getByTestId('data-testid Data source settings page Alert')).toBeInTheDocument(); + expect(screen.queryByTestId('data-testid Alert error')).not.toBeInTheDocument(); }); it('should render successful message when testing status is lowercase "ok"', () => { @@ -62,8 +69,8 @@ describe('', () => { render(); expect(screen.getByText('Data source is definitely working')).toBeInTheDocument(); - expect(screen.getByTestId('data-testid Alert success')).toBeInTheDocument(); - expect(() => screen.getByTestId('data-testid Alert error')).toThrow(); + expect(screen.getByTestId('data-testid Data source settings page Alert')).toBeInTheDocument(); + expect(screen.queryByTestId('data-testid Alert error')).not.toBeInTheDocument(); }); it('should render error message when testing status is "error"', () => { @@ -76,8 +83,8 @@ describe('', () => { render(); expect(screen.getByText('Data source is definitely NOT working')).toBeInTheDocument(); - expect(screen.getByTestId('data-testid Alert error')).toBeInTheDocument(); - expect(() => screen.getByTestId('data-testid Alert success')).toThrow(); + expect(screen.getByTestId('data-testid Data source settings page Alert')).toBeInTheDocument(); + expect(screen.queryByTestId('data-testid Alert success')).not.toBeInTheDocument(); }); it('should render info message when testing status is unknown', () => { @@ -90,9 +97,9 @@ describe('', () => { render(); expect(screen.getByText('Data source is working')).toBeInTheDocument(); - expect(screen.getByTestId('data-testid Alert info')).toBeInTheDocument(); - expect(() => screen.getByTestId('data-testid Alert success')).toThrow(); - expect(() => screen.getByTestId('data-testid Alert error')).toThrow(); + expect(screen.getByTestId('data-testid Data source settings page Alert')).toBeInTheDocument(); + expect(screen.queryByTestId('data-testid Alert success')).not.toBeInTheDocument(); + expect(screen.queryByTestId('data-testid Alert error')).not.toBeInTheDocument(); }); describe('Plugin links', () => { @@ -111,24 +118,50 @@ describe('', () => { type: PluginExtensionTypes.link as const, title: 'Test Link', description: 'Test link description', - pluginId: 'test-plugin', + pluginId: 'grafana-monitoring-app', // Use an allowed plugin ID path: '/test', onClick: jest.fn(), ...overrides, }); + // Custom mock that can handle different extension points + const setupPluginLinksMock = (statusLinks: PluginExtensionLink[] = [], errorLinks: PluginExtensionLink[] = []) => { + setPluginLinksHook((params: UsePluginLinksOptions) => { + // Return different links based on the extension point ID + if (params.extensionPointId === 'grafana/datasources/config/status') { + return { links: statusLinks, isLoading: false }; + } else if (params.extensionPointId === 'grafana/datasources/config/error-status') { + return { links: errorLinks, isLoading: false }; + } + return { links: [], isLoading: false }; + }); + }; + afterEach(() => { // Reset the hook to default empty state setPluginLinksHook(() => ({ links: [], isLoading: false })); }); it('should render plugin links when severity is error and links exist', () => { - const mockLinks = [ - createMockPluginLink({ id: 'link1', path: 'http://example.com/help', title: 'Help Documentation' }), - createMockPluginLink({ id: 'link2', path: 'http://example.com/troubleshoot', title: 'Troubleshooting Guide' }), + const statusLinks = [ + createMockPluginLink({ + id: 'status-link1', + path: 'http://example.com/help', + title: 'Help Documentation', + pluginId: 'grafana-monitoring-app', + }), ]; - setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + const errorLinks = [ + createMockPluginLink({ + id: 'error-link1', + path: 'http://example.com/troubleshoot', + title: 'Troubleshooting Guide', + pluginId: 'grafana-troubleshooting-app', + }), + ]; + + setupPluginLinksMock(statusLinks, errorLinks); const props = getProps({ testingStatus: { @@ -151,16 +184,17 @@ describe('', () => { it('should call onClick handler when plugin link is clicked', () => { const mockOnClick = jest.fn(); - const mockLinks = [ + const statusLinks = [ createMockPluginLink({ - id: 'link1', + id: 'status-link1', path: 'http://example.com/help', onClick: mockOnClick, title: 'Help Documentation', + pluginId: 'grafana-monitoring-app', }), ]; - setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + setupPluginLinksMock(statusLinks, []); const props = getProps({ testingStatus: { @@ -177,12 +211,26 @@ describe('', () => { expect(mockOnClick).toHaveBeenCalledTimes(1); }); - it('should NOT render plugin links when severity is not error even if links exist', () => { - const mockLinks = [ - createMockPluginLink({ id: 'link1', path: 'http://example.com/help', title: 'Help Documentation' }), + it('should render status plugin links for non-error severity but NOT error-specific links', () => { + const statusLinks = [ + createMockPluginLink({ + id: 'status-link1', + path: 'http://example.com/help', + title: 'Status Help Documentation', + pluginId: 'grafana-monitoring-app', + }), ]; - setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + const errorLinks = [ + createMockPluginLink({ + id: 'error-link1', + path: 'http://example.com/error-help', + title: 'Error Help Documentation', + pluginId: 'grafana-troubleshooting-app', + }), + ]; + + setupPluginLinksMock(statusLinks, errorLinks); const props = getProps({ testingStatus: { @@ -193,6 +241,33 @@ describe('', () => { render(); + // Should render status links for success severity + expect(screen.getByText('Status Help Documentation')).toBeInTheDocument(); + // Should NOT render error-specific links for success severity + expect(screen.queryByText('Error Help Documentation')).not.toBeInTheDocument(); + }); + + it('should NOT render plugin links from non-allowed plugins', () => { + const statusLinks = [ + createMockPluginLink({ + id: 'status-link1', + path: 'http://example.com/help', + title: 'Help Documentation', + pluginId: 'not-allowed-plugin', // This should be filtered out + }), + ]; + + setupPluginLinksMock(statusLinks, []); + + const props = getProps({ + testingStatus: { + status: 'error', + message: 'Data source connection failed', + }, + }); + + render(); + expect(screen.queryByText('Help Documentation')).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/datasources/components/DataSourceTestingStatus.tsx b/public/app/features/datasources/components/DataSourceTestingStatus.tsx index 813e2e55aa8..234a49c6635 100644 --- a/public/app/features/datasources/components/DataSourceTestingStatus.tsx +++ b/public/app/features/datasources/components/DataSourceTestingStatus.tsx @@ -1,7 +1,12 @@ import { css, cx } from '@emotion/css'; import { HTMLAttributes } from 'react'; -import { DataSourceSettings as DataSourceSettingsType, GrafanaTheme2, PluginExtensionPoints } from '@grafana/data'; +import { + DataSourceSettings as DataSourceSettingsType, + GrafanaTheme2, + PluginExtensionPoints, + PluginExtensionLink, +} from '@grafana/data'; import { sanitizeUrl } from '@grafana/data/internal'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; @@ -9,6 +14,7 @@ import { TestingStatus, config, usePluginLinks } from '@grafana/runtime'; import { AlertVariant, Alert, useTheme2, Link, useStyles2 } from '@grafana/ui'; import { contextSrv } from '../../../core/core'; +import { ALLOWED_DATASOURCE_EXTENSION_PLUGINS } from '../constants'; import { trackCreateDashboardClicked } from '../tracking'; export type Props = { @@ -23,6 +29,7 @@ interface AlertMessageProps extends HTMLAttributes { exploreUrl: string; dataSourceId: string; onDashboardLinkClicked: () => void; + extensionLinks?: PluginExtensionLink[]; } const getStyles = (theme: GrafanaTheme2, hasTitle: boolean) => { @@ -37,10 +44,21 @@ const getStyles = (theme: GrafanaTheme2, hasTitle: boolean) => { pointerEvents: 'none', color: theme.colors.text.secondary, }), + extensionLinks: css({ + display: 'inline-flex', + marginTop: theme.spacing(0.5), + gap: theme.spacing(1), + }), }; }; -const AlertSuccessMessage = ({ title, exploreUrl, dataSourceId, onDashboardLinkClicked }: AlertMessageProps) => { +const AlertSuccessMessage = ({ + title, + exploreUrl, + dataSourceId, + onDashboardLinkClicked, + extensionLinks = [], +}: AlertMessageProps) => { const theme = useTheme2(); const hasTitle = Boolean(title); @@ -72,6 +90,26 @@ const AlertSuccessMessage = ({ title, exploreUrl, dataSourceId, onDashboardLinkC . + + {/* Extension links for allowed datasource extension plugins */} + {extensionLinks.length > 0 && ( +
+ + You can also explore data with the following extensions: + + {extensionLinks.map((link) => ( + + {link.title} + + ))} +
+ )} ); }; @@ -148,7 +186,24 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource }); }; const styles = useStyles2(getTestingStatusStyles); - const { links } = usePluginLinks({ + + const { links: allStatusLinks } = usePluginLinks({ + extensionPointId: PluginExtensionPoints.DataSourceConfigStatus, + context: { + dataSource: { + type: dataSource.type, + uid: dataSource.uid, + name: dataSource.name, + typeName: dataSource.typeName, + }, + testingStatus, + severity, + }, + limitPerPlugin: 1, + }); + + // Existing error-specific extensions (backward compatibility) + const { links: allErrorLinks } = usePluginLinks({ extensionPointId: PluginExtensionPoints.DataSourceConfigErrorStatus, context: { dataSource: { @@ -161,6 +216,13 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource limitPerPlugin: 3, }); + // Filter to only allow grafana-owned plugins + const statusLinks = allStatusLinks.filter((link) => ALLOWED_DATASOURCE_EXTENSION_PLUGINS.includes(link.pluginId)); + const errorLinks = allErrorLinks.filter((link) => ALLOWED_DATASOURCE_EXTENSION_PLUGINS.includes(link.pluginId)); + + // Combine links: show error-specific only for errors, status-general for all + const extensionLinks = severity === 'error' ? [...statusLinks, ...errorLinks] : statusLinks; + if (message) { return (
@@ -174,6 +236,7 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource exploreUrl={exploreUrl} dataSourceId={dataSource.uid} onDashboardLinkClicked={onDashboardLinkClicked} + extensionLinks={extensionLinks} /> ) : null} {severity === 'error' && errorDetailsLink ? : null} @@ -182,15 +245,16 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource ) : null} )} - {severity === 'error' && links.length > 0 && ( + {extensionLinks.length > 0 && (
- {links.map((link) => { + {extensionLinks.map((link) => { return ( {link.title} diff --git a/public/app/features/datasources/components/EditDataSourceActions.test.tsx b/public/app/features/datasources/components/EditDataSourceActions.test.tsx new file mode 100644 index 00000000000..0bd748c900e --- /dev/null +++ b/public/app/features/datasources/components/EditDataSourceActions.test.tsx @@ -0,0 +1,321 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +import { PluginExtensionTypes, IconName } from '@grafana/data'; +import { setPluginLinksHook } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; + +import { getMockDataSource } from '../mocks/dataSourcesMocks'; + +import { EditDataSourceActions } from './EditDataSourceActions'; + +// Mock dependencies +jest.mock('app/core/services/context_srv'); +jest.mock('../utils', () => ({ + constructDataSourceExploreUrl: jest.fn( + () => '/explore?left=%7B%22datasource%22:%22Test%20Prometheus%22,%22context%22:%22explore%22%7D' + ), +})); + +// Set default plugin links hook +setPluginLinksHook(() => ({ links: [], isLoading: false })); + +// Mock contextSrv +const mockContextSrv = contextSrv as jest.Mocked; + +// Helper function to create mock plugin link extensions with all required properties +const createMockPluginLink = ( + overrides: Partial<{ + id: string; + path: string; + onClick: jest.Mock; + title: string; + description: string; + pluginId: string; + icon?: IconName; + }> = {} +) => ({ + id: 'test-link', + type: PluginExtensionTypes.link as const, + title: 'Test Action', + description: 'Test action description', + pluginId: 'grafana-lokiexplore-app', + path: '/test-action', + onClick: jest.fn(), + ...overrides, +}); + +const mockDataSource = getMockDataSource({ + uid: 'test-uid', + type: 'prometheus', + name: 'Test Prometheus', + typeName: 'Prometheus', +}); + +// Mock useDataSource hook +jest.mock('../state/hooks', () => ({ + useDataSource: () => mockDataSource, +})); + +describe('EditDataSourceActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset plugin links hook to default + setPluginLinksHook(() => ({ links: [], isLoading: false })); + // Default contextSrv mock - user has explore rights + mockContextSrv.hasAccessToExplore.mockReturnValue(true); + }); + + describe('Core Actions', () => { + it('should render core Grafana actions when user has explore rights', async () => { + mockContextSrv.hasAccessToExplore.mockReturnValue(true); + + render(); + + // Core actions should be rendered as separate buttons + expect(screen.getByText('Explore data')).toBeInTheDocument(); + expect(screen.getByText('Build a dashboard')).toBeInTheDocument(); + }); + + it('should not render explore action when user lacks explore rights', () => { + mockContextSrv.hasAccessToExplore.mockReturnValue(false); + + render(); + + // Should render just the "Build a dashboard" button + expect(screen.getByText('Build a dashboard')).toBeInTheDocument(); + // Should not render explore action + expect(screen.queryByText('Explore data')).not.toBeInTheDocument(); + }); + + it('should have correct href for explore action when no extensions', () => { + // No extensions, so explore should be a direct link + setPluginLinksHook(() => ({ links: [], isLoading: false })); + + render(); + + const exploreLink = screen.getByText('Explore data').closest('a'); + // The explore URL uses the datasource name, not uid, and includes context + expect(exploreLink).toHaveAttribute( + 'href', + '/explore?left=%7B%22datasource%22:%22Test%20Prometheus%22,%22context%22:%22explore%22%7D' + ); + }); + + it('should have correct href for explore action when extensions are present', () => { + // Extensions present, so explore should be a dropdown with "Open in Explore View" + const mockLinks = [ + createMockPluginLink({ + id: 'test-extension', + title: 'Test Extension', + pluginId: 'grafana-lokiexplore-app', + }), + ]; + + setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + + render(); + + // Click to open dropdown + const exploreButton = screen.getByText('Explore data'); + fireEvent.click(exploreButton); + + const exploreViewLink = screen.getByText('Open in Explore View').closest('a'); + // The explore URL uses the datasource name, not uid, and includes context + expect(exploreViewLink).toHaveAttribute( + 'href', + '/explore?left=%7B%22datasource%22:%22Test%20Prometheus%22,%22context%22:%22explore%22%7D' + ); + }); + + it('should have correct href for build dashboard action', () => { + render(); + + const dashboardLink = screen.getByText('Build a dashboard').closest('a'); + expect(dashboardLink).toHaveAttribute('href', 'dashboard/new-with-ds/test-uid'); + }); + }); + + describe('Plugin Extension Actions', () => { + it('should render plugin extension links from allowed plugins', () => { + const mockLinks = [ + createMockPluginLink({ + id: 'loki-explore', + title: 'Explore Logs', + pluginId: 'grafana-lokiexplore-app', + path: '/a/grafana-lokiexplore-app', + }), + createMockPluginLink({ + id: 'traces-explore', + title: 'Explore Traces', + pluginId: 'grafana-exploretraces-app', + path: '/a/grafana-exploretraces-app', + }), + ]; + + setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + + render(); + + // Click the Explore data dropdown to open the menu + const exploreButton = screen.getByText('Explore data'); + fireEvent.click(exploreButton); + + // Should have "Open in Explore View" as first item + expect(screen.getByText('Open in Explore View')).toBeInTheDocument(); + // Should have extension links + expect(screen.getByText('Explore Logs')).toBeInTheDocument(); + expect(screen.getByText('Explore Traces')).toBeInTheDocument(); + }); + + it('should filter out links from non-allowed plugins', () => { + const mockLinks = [ + createMockPluginLink({ + id: 'allowed-plugin', + title: 'Allowed Action', + pluginId: 'grafana-lokiexplore-app', // Allowed + }), + createMockPluginLink({ + id: 'disallowed-plugin', + title: 'Disallowed Action', + pluginId: 'some-random-plugin', // Not allowed + }), + ]; + + setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + + render(); + + // Click the Explore data dropdown to open the menu + const exploreButton = screen.getByText('Explore data'); + fireEvent.click(exploreButton); + + expect(screen.getByText('Allowed Action')).toBeInTheDocument(); + expect(screen.queryByText('Disallowed Action')).not.toBeInTheDocument(); + }); + + it('should call usePluginLinks with correct parameters', () => { + // This test verifies the component calls usePluginLinks correctly + // We can't easily test the exact parameters without more complex mocking + // but we can verify the component renders without errors when links are provided + setPluginLinksHook(() => ({ links: [], isLoading: false })); + + expect(() => { + render(); + }).not.toThrow(); + }); + + it('should handle plugin link onClick events', () => { + const mockOnClick = jest.fn(); + const mockLinks = [ + createMockPluginLink({ + id: 'clickable-action', + title: 'Clickable Action', + onClick: mockOnClick, + pluginId: 'grafana-lokiexplore-app', + }), + ]; + + setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + + render(); + + // Click the Explore data dropdown to open the menu + const exploreButton = screen.getByText('Explore data'); + fireEvent.click(exploreButton); + + const actionButton = screen.getByText('Clickable Action'); + fireEvent.click(actionButton); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('should render extension links with correct attributes', () => { + const mockLinks = [ + createMockPluginLink({ + id: 'test-action', + title: 'Test Action', + description: 'Test description', + path: '/test-path', + icon: 'external-link-alt' as IconName, + pluginId: 'grafana-lokiexplore-app', + }), + ]; + + setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + + render(); + + // Click the Explore data dropdown to open the menu + const exploreButton = screen.getByText('Explore data'); + fireEvent.click(exploreButton); + + const actionButton = screen.getByText('Test Action'); + const linkElement = actionButton.closest('a'); + + expect(linkElement).toHaveAttribute('href', '/test-path'); + // The description is passed as tooltip, which may not appear as a title attribute + // This is handled by the LinkButton component internally + }); + + it('should not render extensions when isLoading is true', () => { + const mockLinks = [ + createMockPluginLink({ + title: 'Should Not Appear', + pluginId: 'grafana-lokiexplore-app', + }), + ]; + + setPluginLinksHook(() => ({ links: mockLinks, isLoading: true })); + + render(); + + // When isLoading is true, Explore data should be a regular link, not a dropdown + const exploreElement = screen.getByText('Explore data'); + const exploreLink = exploreElement.closest('a'); + expect(exploreLink).toBeInTheDocument(); + + expect(screen.queryByText('Should Not Appear')).not.toBeInTheDocument(); + // Core actions should still be there + expect(screen.getByText('Build a dashboard')).toBeInTheDocument(); + expect(screen.getByText('Explore data')).toBeInTheDocument(); + }); + + it('should handle empty extension links gracefully', () => { + setPluginLinksHook(() => ({ links: [], isLoading: false })); + + render(); + + // When there are no extension links, Explore data should be a regular link + const exploreElement = screen.getByText('Explore data'); + const exploreLink = exploreElement.closest('a'); + expect(exploreLink).toBeInTheDocument(); + + // Should render core actions without errors + expect(screen.getByText('Build a dashboard')).toBeInTheDocument(); + expect(screen.getByText('Explore data')).toBeInTheDocument(); + }); + + it('should render Explore dropdown when there are plugin links', () => { + const mockLinks = [ + createMockPluginLink({ + id: 'test-extension', + title: 'Test Extension', + pluginId: 'grafana-lokiexplore-app', + }), + ]; + + setPluginLinksHook(() => ({ links: mockLinks, isLoading: false })); + + render(); + + // When there are extension links, Explore data should be a dropdown button + const exploreElement = screen.getByText('Explore data'); + const exploreButton = exploreElement.closest('button'); + expect(exploreButton).toBeInTheDocument(); + + // Core actions should still be there + expect(screen.getByText('Build a dashboard')).toBeInTheDocument(); + expect(screen.getByText('Explore data')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/datasources/components/EditDataSourceActions.tsx b/public/app/features/datasources/components/EditDataSourceActions.tsx index a2d18bb11f5..bc231a4c951 100644 --- a/public/app/features/datasources/components/EditDataSourceActions.tsx +++ b/public/app/features/datasources/components/EditDataSourceActions.tsx @@ -1,8 +1,10 @@ -import { Trans } from '@grafana/i18n'; -import { config } from '@grafana/runtime'; -import { LinkButton } from '@grafana/ui'; +import { PluginExtensionPoints } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { config, usePluginLinks } from '@grafana/runtime'; +import { Button, Dropdown, LinkButton, Menu, Icon } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; +import { ALLOWED_DATASOURCE_EXTENSION_PLUGINS } from '../constants'; import { useDataSource } from '../state/hooks'; import { trackCreateDashboardClicked, trackDsConfigClicked, trackExploreClicked } from '../tracking'; import { constructDataSourceExploreUrl } from '../utils'; @@ -15,25 +17,71 @@ export function EditDataSourceActions({ uid }: Props) { const dataSource = useDataSource(uid); const hasExploreRights = contextSrv.hasAccessToExplore(); + // Fetch plugin extension links + const { links: allLinks, isLoading } = usePluginLinks({ + extensionPointId: PluginExtensionPoints.DataSourceConfigActions, + context: { + dataSource: { + type: dataSource.type, + uid: dataSource.uid, + name: dataSource.name, + typeName: dataSource.typeName, + }, + }, + limitPerPlugin: 1, + }); + + const links = allLinks.filter((link) => ALLOWED_DATASOURCE_EXTENSION_PLUGINS.includes(link.pluginId)); + + // Only render dropdown if there are multiple actions to show + const hasActions = !isLoading && links.length > 0; + + const handleExploreClick = () => { + trackDsConfigClicked('explore'); + trackExploreClicked({ + grafana_version: config.buildInfo.version, + datasource_uid: dataSource.uid, + plugin_name: dataSource.typeName, + path: window.location.pathname, + }); + }; + + const exploreMenu = ( + + + {links.map((link) => ( + + ))} + + ); + return ( <> {hasExploreRights && ( - { - trackDsConfigClicked('explore'); - trackExploreClicked({ - grafana_version: config.buildInfo.version, - datasource_uid: dataSource.uid, - plugin_name: dataSource.typeName, - path: window.location.pathname, - }); - }} - > - Explore data - + <> + {!hasActions ? ( + + Explore data + + ) : ( + + + + )} + )} here to learn more about this error.", - "success-more-details-links": "Next, you can start to visualize data by <2>building a dashboard, or by querying data in the <5>Explore view." + "success-more-details-links": "Next, you can start to visualize data by <2>building a dashboard, or by querying data in the <5>Explore view.", + "success-more-details-links-extensions": "You can also explore data with the following extensions:" }, "data-sources": { "datasource-add-button": { @@ -6533,7 +6534,8 @@ }, "edit-data-source-actions": { "build-a-dashboard": "Build a dashboard", - "explore-data": "Explore data" + "explore-data": "Explore data", + "open-in-explore": "Open in Explore View" }, "error-details-link": { "aria-label-more-details-about-the-error": "More details about the error"