diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 5985b95f240..79414124e0b 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -849,6 +849,8 @@ export { defaultActionConfig, contentTypeOptions, httpMethodOptions, + type FetchOptions, + type InfinityOptions, } from './types/action'; export { DataFrameType } from './types/dataFrameTypes'; export { diff --git a/packages/grafana-data/src/types/action.ts b/packages/grafana-data/src/types/action.ts index 3a637ffa071..4c90cce13ae 100644 --- a/packages/grafana-data/src/types/action.ts +++ b/packages/grafana-data/src/types/action.ts @@ -4,6 +4,7 @@ import { SelectableValue } from './select'; export enum ActionType { Fetch = 'fetch', + Infinity = 'infinity', } type ActionButtonCssProperties = Pick; @@ -11,11 +12,8 @@ type ActionButtonCssProperties = Pick; export interface Action { type: ActionType; title: string; - - // Options for the selected type - // Currently this is required because there is only one valid type (fetch) - // once multiple types are valid, usage of this will need to be optional - [ActionType.Fetch]: FetchOptions; + [ActionType.Fetch]?: FetchOptions; + [ActionType.Infinity]?: InfinityOptions; confirmation?: string; oneClick?: boolean; variables?: ActionVariable[]; @@ -44,7 +42,7 @@ export enum ActionVariableType { String = 'string', } -interface FetchOptions { +export interface FetchOptions { method: HttpRequestMethod; url: string; body?: string; @@ -52,15 +50,20 @@ interface FetchOptions { headers?: Array<[string, string]>; } +export interface InfinityOptions extends FetchOptions { + datasourceUid: string; +} + export enum HttpRequestMethod { POST = 'POST', PUT = 'PUT', GET = 'GET', + DELETE = 'DELETE', + PATCH = 'PATCH', } export const httpMethodOptions: SelectableValue[] = [ { label: HttpRequestMethod.POST, value: HttpRequestMethod.POST }, - { label: HttpRequestMethod.PUT, value: HttpRequestMethod.PUT }, { label: HttpRequestMethod.GET, value: HttpRequestMethod.GET }, ]; @@ -74,7 +77,7 @@ export const contentTypeOptions: SelectableValue[] = [ export const defaultActionConfig: Action = { type: ActionType.Fetch, title: '', - fetch: { + [ActionType.Fetch]: { url: '', method: HttpRequestMethod.POST, body: '{}', diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 0c7c5ff91e8..92e443e4867 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -617,6 +617,10 @@ export interface FeatureToggles { */ newFiltersUI?: boolean; /** + * Allows authenticated API calls in actions + */ + vizActionsAuth?: boolean; + /** * Uses Prometheus rules as the primary source of truth for ruler-enabled data sources */ alertingPrometheusRulesPrimary?: boolean; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx index 2f505e3bd1b..c3386a7bf3c 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksInlineEditor/DataLinksListItemBase.tsx @@ -31,9 +31,7 @@ export function DataLinksListItemBase({ const styles = useStyles2(getDataLinkListItemStyles); const { title = '', oneClick = false } = item; - // @ts-ignore - https://github.com/microsoft/TypeScript/issues/27808 - const url = item.url ?? item.fetch?.url ?? ''; - + const url = ('type' in item ? item[item.type]?.url : item.url) ?? ''; const hasTitle = title.trim() !== ''; const hasUrl = url.trim() !== ''; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index b05e3e9d211..97ddb007235 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -39,6 +39,7 @@ export interface PanelContext { canAddAnnotations?: () => boolean; canEditAnnotations?: (dashboardUID?: string) => boolean; canDeleteAnnotations?: (dashboardUID?: string) => boolean; + canExecuteActions?: () => boolean; onAnnotationCreate?: (annotation: AnnotationEventUIModel) => void; onAnnotationUpdate?: (annotation: AnnotationEventUIModel) => void; onAnnotationDelete?: (id: string) => void; diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index fe85cbf042c..dc3ab2a08d2 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -133,10 +133,16 @@ export function TableNG(props: TableNGProps) { const theme = useTheme2(); const styles = useStyles2(getGridStyles, enablePagination, transparent); const panelContext = usePanelContext(); + const userCanExecuteActions = useMemo(() => panelContext.canExecuteActions?.() ?? false, [panelContext]); const getCellActions = useCallback( - (field: Field, rowIdx: number) => getActions(data, field, rowIdx), - [getActions, data] + (field: Field, rowIdx: number) => { + if (!userCanExecuteActions) { + return []; + } + return getActions(data, field, rowIdx); + }, + [getActions, data, userCanExecuteActions] ); const hasHeader = !noHeader; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 2c0ed4508a0..3b7cbf12635 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1060,6 +1060,15 @@ var ( Owner: grafanaDashboardsSquad, Expression: "true", // enabled by default }, + { + Name: "vizActionsAuth", + Description: "Allows authenticated API calls in actions", + Stage: FeatureStagePublicPreview, + Owner: grafanaDatavizSquad, + FrontendOnly: true, + HideFromAdminPage: true, + HideFromDocs: true, + }, { Name: "alertingPrometheusRulesPrimary", Description: "Uses Prometheus rules as the primary source of truth for ruler-enabled data sources", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 1158ef9b294..9375805f33e 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -137,6 +137,7 @@ prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,fa alertingFilterV2,experimental,@grafana/alerting-squad,false,false,false dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false newFiltersUI,GA,@grafana/dashboards-squad,false,false,false +vizActionsAuth,preview,@grafana/dataviz-squad,false,false,true alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index a57b26d155f..af44154ebeb 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -559,6 +559,10 @@ const ( // Enables new combobox style UI for the Ad hoc filters variable in scenes architecture FlagNewFiltersUI = "newFiltersUI" + // FlagVizActionsAuth + // Allows authenticated API calls in actions + FlagVizActionsAuth = "vizActionsAuth" + // FlagAlertingPrometheusRulesPrimary // Uses Prometheus rules as the primary source of truth for ruler-enabled data sources FlagAlertingPrometheusRulesPrimary = "alertingPrometheusRulesPrimary" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 9c03409c5b3..8c83e999c64 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3536,6 +3536,24 @@ "expression": "true" } }, + { + "metadata": { + "name": "vizActionsAuth", + "resourceVersion": "1756904995830", + "creationTimestamp": "2025-08-08T18:59:18Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-09-03 13:09:55.830412 +0000 UTC" + } + }, + "spec": { + "description": "Allows authenticated API calls in actions", + "stage": "preview", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "zanzana", diff --git a/public/app/features/actions/ActionEditor.test.tsx b/public/app/features/actions/ActionEditor.test.tsx new file mode 100644 index 00000000000..32f4daf7373 --- /dev/null +++ b/public/app/features/actions/ActionEditor.test.tsx @@ -0,0 +1,175 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + Action, + ActionType, + defaultActionConfig, + VariableSuggestion, + VariableOrigin, + HttpRequestMethod, +} from '@grafana/data'; + +import { ActionEditor } from './ActionEditor'; + +describe('ActionEditor', () => { + const mockOnChange = jest.fn(); + + const mockSuggestions: VariableSuggestion[] = [ + { value: '${var1}', label: 'Variable 1', origin: VariableOrigin.BuiltIn }, + { value: '${var2}', label: 'Variable 2', origin: VariableOrigin.BuiltIn }, + ]; + + const defaultAction: Action = { + ...defaultActionConfig, + title: 'Test Action', + type: ActionType.Fetch, + [ActionType.Fetch]: { + method: HttpRequestMethod.POST, + url: 'https://api.example.com', + body: '{}', + queryParams: [], + headers: [['Content-Type', 'application/json']], + }, + }; + + const defaultProps = { + index: 0, + value: defaultAction, + onChange: mockOnChange, + suggestions: mockSuggestions, + showOneClick: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders action editor with basic fields', () => { + render(); + + expect(screen.getByDisplayValue('Test Action')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://api.example.com')).toBeInTheDocument(); + expect(screen.getByText('Connection')).toBeInTheDocument(); + expect(screen.getByText('Variables')).toBeInTheDocument(); + expect(screen.getByText('Query parameters')).toBeInTheDocument(); + expect(screen.getByText('Headers')).toBeInTheDocument(); + }); + + it('toggles one click setting', async () => { + const user = userEvent.setup(); + render(); + + const oneClickSwitch = screen.getByRole('switch'); + await user.click(oneClickSwitch); + + expect(mockOnChange).toHaveBeenCalledWith(0, { + ...defaultAction, + oneClick: !defaultAction.oneClick, + }); + }); + + it('updates HTTP method', async () => { + const user = userEvent.setup(); + render(); + + const getMethodButton = screen.getByRole('radio', { name: 'GET' }); + await user.click(getMethodButton); + + expect(mockOnChange).toHaveBeenCalledWith(0, { + ...defaultAction, + [ActionType.Fetch]: { + ...defaultAction[ActionType.Fetch], + method: HttpRequestMethod.GET, + }, + }); + }); + + it('renders color picker for background color', () => { + render(); + + expect(screen.getByText('Button style')).toBeInTheDocument(); + expect(screen.getByText('Color')).toBeInTheDocument(); + }); + + it('hides body field for GET requests', () => { + const getAction: Action = { + ...defaultAction, + [ActionType.Fetch]: { + ...defaultAction[ActionType.Fetch]!, + method: HttpRequestMethod.GET, + }, + }; + + render(); + + expect(screen.queryByDisplayValue('{}')).not.toBeInTheDocument(); + }); + + describe('Connection functionality', () => { + it('renders connection picker section', () => { + render(); + + expect(screen.getByText('Connection')).toBeInTheDocument(); + }); + + it('renders connection picker for Infinity action type', () => { + const proxyAction: Action = { + ...defaultAction, + type: ActionType.Infinity, + [ActionType.Infinity]: { + method: HttpRequestMethod.POST, + url: 'https://api.example.com', + body: '{}', + queryParams: [], + headers: [['Content-Type', 'application/json']], + datasourceUid: 'test-ds-uid', + }, + }; + + render(); + + expect(screen.getByText('Connection')).toBeInTheDocument(); + }); + + it('renders with fetch action type showing direct connection', () => { + const fetchAction: Action = { + ...defaultAction, + type: ActionType.Fetch, + [ActionType.Fetch]: { + method: HttpRequestMethod.POST, + url: 'https://api.example.com', + body: '{}', + queryParams: [], + headers: [['Content-Type', 'application/json']], + }, + }; + + render(); + + expect(screen.getByText('Connection')).toBeInTheDocument(); + expect(screen.getByText('Direct from browser')).toBeInTheDocument(); + }); + + it('renders with Infinity action type showing datasource connection', () => { + const proxyAction: Action = { + ...defaultAction, + type: ActionType.Infinity, + [ActionType.Infinity]: { + method: HttpRequestMethod.POST, + url: 'https://api.example.com', + body: '{}', + queryParams: [], + headers: [['Content-Type', 'application/json']], + datasourceUid: 'test-datasource-uid', + }, + }; + + render(); + + expect(screen.getByText('Connection')).toBeInTheDocument(); + const connectionSection = screen.getByText('Connection').closest('.css-15ix71y-InlineFieldRow'); + expect(connectionSection).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/actions/ActionEditor.tsx b/public/app/features/actions/ActionEditor.tsx index d80bb859ba4..4649ba09b84 100644 --- a/public/app/features/actions/ActionEditor.tsx +++ b/public/app/features/actions/ActionEditor.tsx @@ -3,28 +3,33 @@ import { memo } from 'react'; import { Action, + ActionType, + DataSourceInstanceSettings, GrafanaTheme2, httpMethodOptions, HttpRequestMethod, VariableSuggestion, + InfinityOptions, + FetchOptions, ActionVariable, } from '@grafana/data'; import { t } from '@grafana/i18n'; import { - Switch, + ColorPicker, Field, InlineField, InlineFieldRow, - RadioButtonGroup, JSONFormatter, + RadioButtonGroup, + Switch, useStyles2, - ColorPicker, useTheme2, } from '@grafana/ui'; import { HTMLElementType, SuggestionsInput } from '../transformers/suggestionsInput/SuggestionsInput'; import { ActionVariablesEditor } from './ActionVariablesEditor'; +import { ConnectionPicker } from './ConnectionPicker'; import { ParamsEditor } from './ParamsEditor'; interface ActionEditorProps { @@ -37,10 +42,56 @@ interface ActionEditorProps { const LABEL_WIDTH = 13; +const DEFAULT_HTTP_CONFIG: FetchOptions = { + method: HttpRequestMethod.POST, + url: '', + body: '{}', + queryParams: [], + headers: [['Content-Type', 'application/json']], +}; + export const ActionEditor = memo(({ index, value, onChange, suggestions, showOneClick }: ActionEditorProps) => { const styles = useStyles2(getStyles); const theme = useTheme2(); + const getActionConfig = (): FetchOptions | InfinityOptions => { + if (value.type === ActionType.Infinity) { + return ( + value[ActionType.Infinity] || { + ...DEFAULT_HTTP_CONFIG, + datasourceUid: '', + } + ); + } + + return value[ActionType.Fetch] || DEFAULT_HTTP_CONFIG; + }; + + const updateActionConfig = (updates: Partial) => { + const configKey = value.type === ActionType.Infinity ? ActionType.Infinity : ActionType.Fetch; + const baseConfig = getActionConfig(); + + const updatedConfig = { + ...baseConfig, + ...updates, + ...(value.type === ActionType.Infinity && { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + datasourceUid: (baseConfig as InfinityOptions).datasourceUid || '', + }), + }; + + onChange(index, { + ...value, + [configKey]: updatedConfig, + }); + }; + + const updateConfig = + (field: K) => + (newValue: (FetchOptions & InfinityOptions)[K]) => { + updateActionConfig({ [field]: newValue }); + }; + const onTitleChange = (title: string) => { onChange(index, { ...value, title }); }; @@ -49,40 +100,6 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne onChange(index, { ...value, confirmation }); }; - const onOneClickChanged = () => { - onChange(index, { ...value, oneClick: !value.oneClick }); - }; - - const onUrlChange = (url: string) => { - onChange(index, { - ...value, - fetch: { - ...value.fetch, - url, - }, - }); - }; - - const onBodyChange = (body: string) => { - onChange(index, { - ...value, - fetch: { - ...value.fetch, - body, - }, - }); - }; - - const onMethodChange = (method: HttpRequestMethod) => { - onChange(index, { - ...value, - fetch: { - ...value.fetch, - method, - }, - }); - }; - const onVariablesChange = (variables: ActionVariable[]) => { onChange(index, { ...value, @@ -90,25 +107,15 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne }); }; - const onQueryParamsChange = (queryParams: Array<[string, string]>) => { - onChange(index, { - ...value, - fetch: { - ...value.fetch, - queryParams, - }, - }); + const onOneClickChanged = () => { + onChange(index, { ...value, oneClick: !value.oneClick }); }; - const onHeadersChange = (headers: Array<[string, string]>) => { - onChange(index, { - ...value, - fetch: { - ...value.fetch, - headers, - }, - }); - }; + const onUrlChange = updateConfig('url'); + const onBodyChange = updateConfig('body'); + const onMethodChange = updateConfig('method'); + const onQueryParamsChange = updateConfig('queryParams'); + const onHeadersChange = updateConfig('headers'); const onBackgroundColorChange = (backgroundColor: string) => { onChange(index, { @@ -120,6 +127,33 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne }); }; + const onConnectionChange = (connectionType: string | DataSourceInstanceSettings) => { + const baseAction = { + title: value.title, + confirmation: value.confirmation, + oneClick: value.oneClick, + variables: value.variables, + style: value.style, + }; + + if (typeof connectionType === 'string') { + onChange(index, { + ...baseAction, + type: ActionType.Fetch, + [ActionType.Fetch]: getActionConfig(), + }); + } else { + onChange(index, { + ...baseAction, + type: ActionType.Infinity, + [ActionType.Infinity]: { + ...getActionConfig(), + datasourceUid: connectionType.uid, + }, + }); + } + }; + const renderJSON = (data = '{}') => { try { const json = JSON.parse(data); @@ -133,9 +167,12 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne } }; + const actionConfig = getActionConfig(); const shouldRenderJSON = - value.fetch.method !== HttpRequestMethod.GET && - value.fetch.headers?.some(([name, value]) => name === 'Content-Type' && value === 'application/json'); + actionConfig.method !== HttpRequestMethod.GET && + actionConfig.headers?.some( + ([name, value]: [string, string]) => name === 'Content-Type' && value === 'application/json' + ); return (
@@ -168,20 +205,6 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne )} /> - - - - - - - {showOneClick && ( - + + + + + + + - value={value?.fetch.method} + value={actionConfig.method} options={httpMethodOptions} onChange={onMethodChange} fullWidth @@ -212,7 +241,7 @@ export const ActionEditor = memo(({ index, value, onChange, suggestions, showOne - + - {value?.fetch.method !== HttpRequestMethod.GET && ( + {actionConfig.method !== HttpRequestMethod.GET && (
- {renderJSON(value?.fetch.body)} + {renderJSON(actionConfig.body)} )} + + + + + +
); }); diff --git a/public/app/features/actions/ActionEditorModalContent.tsx b/public/app/features/actions/ActionEditorModalContent.tsx index 99188af5f8e..fdbcafe3bf1 100644 --- a/public/app/features/actions/ActionEditorModalContent.tsx +++ b/public/app/features/actions/ActionEditorModalContent.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { Action, DataFrame, VariableSuggestion } from '@grafana/data'; +import { Action, ActionType, DataFrame, VariableSuggestion } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { Button, Modal } from '@grafana/ui'; @@ -26,6 +26,11 @@ export const ActionEditorModalContent = ({ }: ActionEditorModalContentProps) => { const [dirtyAction, setDirtyAction] = useState(action); + const isSaveButtonDisabled = + dirtyAction.title.trim() === '' || + !dirtyAction[dirtyAction.type]?.url?.trim() || + (dirtyAction.type === ActionType.Infinity && !dirtyAction[ActionType.Infinity]?.datasourceUid); + return ( <> { onSave(index, dirtyAction); }} - disabled={dirtyAction.title.trim() === '' || dirtyAction.fetch.url.trim() === ''} + disabled={isSaveButtonDisabled} > Save diff --git a/public/app/features/actions/ConnectionPicker.tsx b/public/app/features/actions/ConnectionPicker.tsx new file mode 100644 index 00000000000..fb6f931c8fb --- /dev/null +++ b/public/app/features/actions/ConnectionPicker.tsx @@ -0,0 +1,96 @@ +import { useMemo } from 'react'; + +import { ActionType, DataSourceInstanceSettings } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { config, getDataSourceSrv } from '@grafana/runtime'; +import { Select } from '@grafana/ui'; + +import { INFINITY_DATASOURCE_TYPE } from './utils'; + +interface ConnectionOption { + label: string; + value: string; + description?: string; + imgUrl?: string; + icon?: string; +} + +interface ConnectionPickerProps { + actionType: ActionType; + datasourceUid?: string; + onChange: (connectionType: 'direct' | DataSourceInstanceSettings) => void; +} + +const DIRECT_OPTION_VALUE = 'direct'; + +const getSupportedDataSources = () => { + const dataSourceSrv = getDataSourceSrv(); + + return dataSourceSrv.getList({ + filter: (ds) => ds.type === INFINITY_DATASOURCE_TYPE, + }); +}; + +export const ConnectionPicker = ({ actionType, datasourceUid, onChange }: ConnectionPickerProps) => { + const connectionOptions: ConnectionOption[] = useMemo(() => { + const options: ConnectionOption[] = [ + { + label: t('grafana-ui.action-editor.modal.connection-direct-label', 'Direct from browser'), + value: DIRECT_OPTION_VALUE, + description: t( + 'grafana-ui.action-editor.modal.connection-direct-description', + 'Make request directly from browser' + ), + icon: 'adjust-circle', + }, + ]; + + if (config.featureToggles.vizActionsAuth) { + const supportedDataSources = getSupportedDataSources(); + + supportedDataSources.forEach((ds) => { + options.push({ + label: ds.name, + value: ds.uid, + imgUrl: ds.meta.info.logos.small, + }); + }); + } + + return options; + }, []); + + const getCurrentValue = () => { + if (actionType === ActionType.Fetch) { + return DIRECT_OPTION_VALUE; + } else if (actionType === ActionType.Infinity && datasourceUid) { + return datasourceUid; + } + return DIRECT_OPTION_VALUE; + }; + + const handleConnectionChange = (selectedValue: string) => { + if (selectedValue === DIRECT_OPTION_VALUE) { + onChange(DIRECT_OPTION_VALUE); + } else { + const supportedDataSources = getSupportedDataSources(); + const selectedDatasource = supportedDataSources.find((ds) => ds.uid === selectedValue); + if (selectedDatasource) { + onChange(selectedDatasource); + } else { + console.error('ConnectionPicker: Could not find datasource with UID:', selectedValue); + } + } + }; + + const currentValue = getCurrentValue(); + + return ( +