diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx index ea32291dd92..bbccf83d663 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx @@ -7,6 +7,7 @@ import { VizPanel } from '@grafana/scenes'; import { Container, ScrollContainer, useStyles2 } from '@grafana/ui'; import { isExpressionQuery } from 'app/features/expressions/guards'; +import { DetailViewHeader } from './DetailViewHeader'; import { ExpressionDetailView } from './ExpressionDetailView'; import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab'; import { QueryDetailView } from './QueryDetailView'; @@ -38,28 +39,37 @@ export const DetailView = memo(({ selectedItem, panel, tabs }: DetailViewProps) if (selectedItem.type === 'query' && 'refId' in selectedItem.data) { const query = selectedItem.data; return ( - - - + <> + + + + + ); } else if (selectedItem.type === 'expression' && 'refId' in selectedItem.data) { const data = selectedItem.data; if (isExpressionQuery(data)) { return ( - - - + <> + + + + + ); } } else { const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations); if (transformsTab instanceof PanelDataTransformationsTab && 'id' in selectedItem.data) { return ( - - - - - + <> + + + + + + + ); } } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx new file mode 100644 index 00000000000..9210909ff31 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx @@ -0,0 +1,403 @@ +import { css, cx } from '@emotion/css'; +import { useCallback, useMemo, useState } from 'react'; + +import { CoreApp, DataQuery, DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; +import { + Button, + Dropdown, + FieldValidationMessage, + Icon, + IconButton, + Input, + Menu, + Stack, + useStyles2, + useTheme2, +} from '@grafana/ui'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; + +import { getQueryRunnerFor } from '../../utils/utils'; + +import { QueryTransformItem } from './QueryTransformList'; + +interface DetailViewHeaderProps { + selectedItem: QueryTransformItem; + panel: VizPanel; +} + +const ITEM_CONFIG = (theme: GrafanaTheme2) => ({ + query: { + color: theme.colors.primary.main, + icon: 'database' as const, + }, + expression: { + color: theme.visualization.getColorByName('purple'), + icon: 'calculator-alt' as const, + }, + transform: { + color: theme.visualization.getColorByName('orange'), + icon: 'process' as const, + }, +}); + +export const DetailViewHeader = ({ selectedItem, panel }: DetailViewHeaderProps) => { + const theme = useTheme2(); + const config = useMemo(() => ITEM_CONFIG(theme)[selectedItem.type], [theme, selectedItem.type]); + const styles = useStyles2(getStyles, config); + + const [isEditing, setIsEditing] = useState(false); + const [validationError, setValidationError] = useState(null); + + // Helper to update queries with consistent pattern + const updateQueries = useCallback( + (updater: (queries: DataQuery[]) => DataQuery[], runAfter = false) => { + const queryRunner = getQueryRunnerFor(panel); + if (!queryRunner) { + return; + } + + const queries = queryRunner.state.queries || []; + const newQueries = updater(queries); + queryRunner.setState({ queries: newQueries }); + + if (runAfter) { + queryRunner.runQueries(); + } + }, + [panel] + ); + + // Get datasource settings for queries + const datasourceSettings = useMemo(() => { + if (selectedItem.type === 'query') { + try { + // If the query has a datasource, use it; otherwise get the default datasource + const datasource = 'datasource' in selectedItem.data ? selectedItem.data.datasource : null; + return getDataSourceSrv().getInstanceSettings(datasource); + } catch { + return getDataSourceSrv().getInstanceSettings(null); + } + } + return undefined; + }, [selectedItem]); + + // Get all queries for validation + const allQueries = useMemo(() => { + const queryRunner = getQueryRunnerFor(panel); + return queryRunner?.state.queries || []; + }, [panel]); + + // Handle datasource change for queries + const handleDataSourceChange = useCallback( + async (newDsSettings: DataSourceInstanceSettings) => { + if (selectedItem.type !== 'query' || selectedItem.index === undefined) { + return; + } + + try { + // Load the new datasource to get its default query + const newDatasource = await getDataSourceSrv().get(newDsSettings.uid); + const defaultQuery = newDatasource.getDefaultQuery?.(CoreApp.PanelEditor) || {}; + + const queryRunner = getQueryRunnerFor(panel); + if (!queryRunner) { + return; + } + + const queries = queryRunner.state.queries || []; + const newQueries = queries.map((q, idx) => { + if (idx === selectedItem.index) { + // Merge default query with existing query to preserve properties + return { + ...defaultQuery, + ...q, + datasource: { uid: newDsSettings.uid, type: newDsSettings.type }, + refId: q.refId, + }; + } + return q; + }); + + // Update queries, datasource, and clear cached data + queryRunner.setState({ + datasource: { uid: newDsSettings.uid, type: newDsSettings.type }, + queries: newQueries, + data: undefined, + }); + + // Run the query with the new datasource + queryRunner.runQueries(); + } catch (error) { + console.error('Error changing datasource:', error); + } + }, + [selectedItem, panel] + ); + + // Handle query name editing + const onEditQueryName = useCallback(() => { + setIsEditing(true); + }, []); + + const onEndEditName = useCallback( + (newName: string) => { + setIsEditing(false); + + // Ignore change if invalid + if (validationError) { + setValidationError(null); + return; + } + + if (!('refId' in selectedItem.data) || selectedItem.data.refId === newName || selectedItem.index === undefined) { + return; + } + + // Update the query with the new refId - just update state, don't run queries + updateQueries((queries) => queries.map((q, idx) => (idx === selectedItem.index ? { ...q, refId: newName } : q))); + }, + [selectedItem, validationError, updateQueries] + ); + + const onInputChange = useCallback( + (event: React.SyntheticEvent) => { + const newName = event.currentTarget.value.trim(); + + if (newName.length === 0) { + setValidationError('An empty query name is not allowed'); + return; + } + + for (const otherQuery of allQueries) { + if (otherQuery !== selectedItem.data && newName === otherQuery.refId) { + setValidationError('Query name already exists'); + return; + } + } + + if (validationError) { + setValidationError(null); + } + }, + [allQueries, selectedItem.data, validationError] + ); + + const onEditQueryBlur = useCallback( + (event: React.SyntheticEvent) => { + onEndEditName(event.currentTarget.value.trim()); + }, + [onEndEditName] + ); + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + onEndEditName(event.currentTarget.value); + } + }, + [onEndEditName] + ); + + const onFocus = useCallback((event: React.FocusEvent) => { + event.target.select(); + }, []); + + // Action handlers + const onCopyQuery = useCallback(() => { + if (selectedItem.index === undefined) { + return; + } + + updateQueries((queries) => { + const queryToCopy = queries[selectedItem.index]; + return queryToCopy ? [...queries, { ...queryToCopy }] : queries; + }); + }, [selectedItem, updateQueries]); + + const onRemoveQuery = useCallback(() => { + if (selectedItem.index === undefined) { + return; + } + + updateQueries((queries) => queries.filter((_, idx) => idx !== selectedItem.index)); + }, [selectedItem, updateQueries]); + + const onToggleHideQuery = useCallback(() => { + if ((selectedItem.type !== 'query' && selectedItem.type !== 'expression') || selectedItem.index === undefined) { + return; + } + + updateQueries( + (queries) => queries.map((q, idx) => (idx === selectedItem.index ? { ...q, hide: !q.hide } : q)), + true // Run queries after update + ); + }, [selectedItem, updateQueries]); + + const onRunQuery = useCallback(() => { + const queryRunner = getQueryRunnerFor(panel); + queryRunner?.runQueries(); + }, [panel]); + + const refId = 'refId' in selectedItem.data ? selectedItem.data.refId : ''; + const isHidden = + (selectedItem.type === 'query' || selectedItem.type === 'expression') && + 'hide' in selectedItem.data && + selectedItem.data.hide; + + return ( +
+ + {/* Left side: Icon, Datasource, Name */} + + + + {/* Datasource picker for queries */} + {selectedItem.type === 'query' && datasourceSettings && ( + + )} + + {/* Editable query/expression name */} + {(selectedItem.type === 'query' || selectedItem.type === 'expression') && refId && ( + <> + {!isEditing ? ( + + ) : ( + <> + + {validationError && {validationError}} + + )} + + )} + + + {/* Right side: Run Query + Actions Menu */} + {(selectedItem.type === 'query' || selectedItem.type === 'expression') && ( + + + + + + + + + } + > + + + + )} + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2, config: { color: string }) => { + return { + header: css({ + padding: theme.spacing(1, 2), + borderLeft: `4px solid ${config.color}`, + borderBottom: `1px solid ${theme.colors.border.weak}`, + background: theme.colors.background.secondary, + minHeight: theme.spacing(6), + }), + icon: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.h5.fontSize, + }), + queryNameWrapper: css({ + display: 'flex', + cursor: 'pointer', + border: '1px solid transparent', + borderRadius: theme.shape.radius.default, + alignItems: 'center', + padding: theme.spacing(0.5, 1), + margin: 0, + background: 'transparent', + overflow: 'hidden', + + '&:hover': { + background: theme.colors.action.hover, + border: `1px dashed ${theme.colors.border.strong}`, + }, + + '&:focus': { + border: `2px solid ${theme.colors.primary.border}`, + }, + + '&:hover, &:focus': { + '.query-name-edit-icon': { + visibility: 'visible', + }, + }, + }), + queryName: css({ + fontWeight: theme.typography.fontWeightMedium, + color: theme.colors.primary.text, + cursor: 'pointer', + overflow: 'hidden', + marginLeft: theme.spacing(0.5), + }), + queryEditIcon: cx( + css({ + marginLeft: theme.spacing(1), + visibility: 'hidden', + }), + 'query-name-edit-icon' + ), + queryNameInput: css({ + maxWidth: '300px', + margin: '-4px 0', + }), + }; +}; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryDetailView.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryDetailView.tsx index 5067a343dd0..9ada0445e35 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryDetailView.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryDetailView.tsx @@ -1,21 +1,13 @@ import { css } from '@emotion/css'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { useAsync } from 'react-use'; -import { - CoreApp, - DataQuery, - DataSourceInstanceSettings, - DataSourcePluginContextProvider, - GrafanaTheme2, - TimeRange, -} from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; +import { CoreApp, DataQuery, DataSourcePluginContextProvider, GrafanaTheme2, TimeRange } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { getDataSourceSrv } from '@grafana/runtime'; import { SceneDataQuery, VizPanel } from '@grafana/scenes'; import { ErrorBoundaryAlert, useStyles2 } from '@grafana/ui'; import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; import { QueryErrorAlert } from 'app/features/query/components/QueryErrorAlert'; import { getQueryRunnerFor } from '../../utils/utils'; @@ -28,20 +20,14 @@ interface QueryDetailViewProps { export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewProps) { const styles = useStyles2(getStyles); - const [datasourceRef, setDatasourceRef] = useState(query.datasource); - let initialDsSettings: DataSourceInstanceSettings | undefined = useMemo(() => { + const dsSettings = useMemo(() => { try { return getDataSourceSrv().getInstanceSettings(query.datasource); } catch { return getDataSourceSrv().getInstanceSettings(null); } - // we want to run this on mount and then never again - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [dsSettings, setDsSettings] = useState(initialDsSettings); - const [needsQueryRun, setNeedsQueryRun] = useState(false); + }, [query.datasource]); const queryRunner = getQueryRunnerFor(panel); const queryRunnerState = queryRunner?.useState(); @@ -51,11 +37,11 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro // FIXME: handle loading and error cases const { value: datasource } = useAsync(async () => { try { - return await getDataSourceSrv().get(datasourceRef); + return await getDataSourceSrv().get(query.datasource); } catch { return await getDataSourceSrv().get(); } - }, [datasourceRef]); + }, [query.datasource]); // Subscribe to panel data const data = useMemo(() => { @@ -72,51 +58,6 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro }; }, [queryRunnerState?.data, query.refId]); - useEffect(() => { - // Update the query with the new datasource - if (queryRunner && datasource) { - const queries = queryRunner.state.queries || []; - const newQueries = queries.map((q, idx) => { - if (idx === queryIndex) { - // Get default query for new datasource - const defaultQuery = datasource.getDefaultQuery?.(CoreApp.PanelEditor) || {}; - return { - ...defaultQuery, - ...q, - datasource: { uid: datasource.uid, type: datasource.type }, - refId: q.refId, - }; - } - return q; - }); - - queryRunner.setState({ - datasource: { uid: datasource.uid, type: datasource.type }, - queries: newQueries, - }); - - if (needsQueryRun) { - queryRunner.runQueries(); - setNeedsQueryRun(false); - } - } - }, [datasource, queryIndex, queryRunner, needsQueryRun]); - - const handleDataSourceChange = useCallback( - async (newDsSettings: DataSourceInstanceSettings) => { - setDatasourceRef({ - // FIXME: apiVersion isn't on the datasources we looked at. - uid: newDsSettings.uid, - type: newDsSettings.type, - }); - setDsSettings(newDsSettings); - if (newDsSettings.uid !== dsSettings?.uid) { - setNeedsQueryRun(true); - } - }, - [dsSettings] - ); - const handleQueryChange = useCallback( (updatedQuery: DataQuery) => { if (queryRunner) { @@ -175,14 +116,6 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro return (
-
- -
- { padding: theme.spacing(2), width: '100%', }), - datasourceSection: css({ - paddingBottom: theme.spacing(2), - }), queryContent: css({ display: 'flex', flexDirection: 'column',