diff --git a/public/app/features/expressions/ExpressionQueryEditor.tsx b/public/app/features/expressions/ExpressionQueryEditor.tsx index de0d59c2920..bb5b98bc382 100644 --- a/public/app/features/expressions/ExpressionQueryEditor.tsx +++ b/public/app/features/expressions/ExpressionQueryEditor.tsx @@ -118,7 +118,16 @@ export function ExpressionQueryEditor(props: ExpressionQueryEditorProps) { return ; case ExpressionQueryType.sql: - return ; + return ( + + ); } }; diff --git a/public/app/features/expressions/components/QueryToolbox.tsx b/public/app/features/expressions/components/QueryToolbox.tsx new file mode 100644 index 00000000000..08a5abf3a99 --- /dev/null +++ b/public/app/features/expressions/components/QueryToolbox.tsx @@ -0,0 +1,104 @@ +import { css } from '@emotion/css'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { IconButton, useStyles2, Stack, InlineToast, Tooltip, Icon } from '@grafana/ui'; + +import { SqlExpressionQuery } from '../types'; + +interface QueryToolboxProps { + onFormatCode?: () => void; + onExpand?: (isExpanded: boolean) => void; + isExpanded?: boolean; + query: SqlExpressionQuery; +} + +const SHOW_SUCCESS_DURATION = 2 * 1000; + +export const QueryToolbox = ({ onFormatCode, onExpand, isExpanded, query }: QueryToolboxProps): JSX.Element => { + const styles = useStyles2(getStyles); + + const [showCopySuccess, setShowCopySuccess] = useState(false); + const buttonRef = useRef(null); + + useEffect(() => { + if (!showCopySuccess) { + return; + } + + const timeoutId = setTimeout(() => { + setShowCopySuccess(false); + }, SHOW_SUCCESS_DURATION); + + return () => clearTimeout(timeoutId); + }, [showCopySuccess]); + + const copyTextCallback = useCallback(async () => { + try { + await navigator.clipboard.writeText(query.expression ?? ''); + setShowCopySuccess(true); + } catch (e) { + console.error(e); + } + }, [query.expression]); + + const copiedText = t('clipboard-button.inline-toast.success', 'Copied'); + + return ( +
+ + {onFormatCode && ( + + )} + {onExpand && ( + onExpand(!isExpanded)} + name={isExpanded ? 'angle-double-up' : 'angle-double-down'} + size="xs" + tooltip={ + isExpanded + ? t('expressions.query-toolbox.tooltip-collapse-editor', 'Collapse editor') + : t('expressions.query-toolbox.tooltip-expand-editor', 'Expand editor') + } + /> + )} + {showCopySuccess && ( + + {copiedText} + + )} + + {!isExpanded && ( + + + + )} + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + border: `1px solid ${theme.colors.border.medium}`, + borderTop: 'none', + padding: theme.spacing(1), + display: 'flex', + flexGrow: 1, + justifyContent: 'end', + fontSize: theme.typography.bodySmall.fontSize, + }), +}); diff --git a/public/app/features/expressions/components/SqlExpr.tsx b/public/app/features/expressions/components/SqlExpr.tsx index 6b1c467168a..ab2d25ab5d3 100644 --- a/public/app/features/expressions/components/SqlExpr.tsx +++ b/public/app/features/expressions/components/SqlExpr.tsx @@ -1,11 +1,14 @@ import { css } from '@emotion/css'; -import { useMemo, useRef, useEffect, useState, lazy, Suspense } from 'react'; +import { useMemo, useRef, useEffect, useState, lazy, Suspense, useCallback } from 'react'; +import { useMeasure } from 'react-use'; +import AutoSizer from 'react-virtualized-auto-sizer'; import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; -import { Trans } from '@grafana/i18n'; +import { t, Trans } from '@grafana/i18n'; import { SQLEditor, CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/plugin-ui'; import { DataQuery } from '@grafana/schema/dist/esm/index'; -import { useStyles2, Stack, Button } from '@grafana/ui'; +import { formatSQL } from '@grafana/sql'; +import { useStyles2, Stack, Button, Modal } from '@grafana/ui'; import { ExpressionQueryEditorProps } from '../ExpressionQueryEditor'; import { SqlExpressionQuery } from '../types'; @@ -13,6 +16,7 @@ import { fetchSQLFields } from '../utils/metaSqlExpr'; import { useSQLExplanations } from './GenAI/hooks/useSQLExplanations'; import { useSQLSuggestions } from './GenAI/hooks/useSQLSuggestions'; +import { QueryToolbox } from './QueryToolbox'; import { getSqlCompletionProvider } from './sqlCompletionProvider'; // Lazy load the GenAI components to avoid circular dependencies @@ -54,12 +58,13 @@ export interface SqlExprProps { query: SqlExpressionQuery; queries: DataQuery[] | undefined; onChange: (query: SqlExpressionQuery) => void; + onRunQuery?: () => void; /** Should the `format` property be set to `alerting`? */ alerting?: boolean; metadata?: ExpressionQueryEditorProps; } -export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries, metadata }: SqlExprProps) => { +export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries, metadata, onRunQuery }: SqlExprProps) => { const vars = useMemo(() => refIds.map((v) => v.value!), [refIds]); const completionProvider = useMemo( () => @@ -74,15 +79,21 @@ export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries, me const EDITOR_LANGUAGE_DEFINITION: LanguageDefinition = { id: 'mysql', completionProvider, + formatter: formatSQL, }; - const initialQuery = `SELECT * - FROM ${vars[0]} - LIMIT 10`; + const initialQuery = `SELECT + * +FROM + ${vars[0]} +LIMIT + 10`; const styles = useStyles2(getStyles); const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ height: 0 }); + const [toolboxRef, toolboxMeasure] = useMeasure(); + const [isExpanded, setIsExpanded] = useState(false); const { handleApplySuggestion, handleHistoryUpdate, handleCloseDrawer, handleOpenDrawer, isDrawerOpen, suggestions } = useSQLSuggestions(); @@ -150,6 +161,12 @@ export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries, me handleApplySuggestion(suggestion); }; + const executeQuery = useCallback(() => { + if (query.expression && onRunQuery) { + onRunQuery(); + } + }, [query.expression, onRunQuery]); + // Set up resize observer to handle container resizing useEffect(() => { if (!containerRef.current) { @@ -174,78 +191,139 @@ export const SqlExpr = ({ onChange, refIds, query, alerting = false, queries, me // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return ( + // cmd/ctrl + enter to run query + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const isMac = navigator.userAgent.includes('Mac'); + const isCmdOrCtrl = isMac ? event.metaKey : event.ctrlKey; + + if (isCmdOrCtrl && event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + executeQuery(); + } + }; + + document.addEventListener('keydown', handleKeyDown, true); + return () => document.removeEventListener('keydown', handleKeyDown, true); + }, [executeQuery]); + + const renderToolbox = (formatQuery: () => void) => ( +
+ +
+ ); + + const renderSQLButtons = () => ( +
+ + + + {shouldShowViewExplanation ? ( + + ) : ( + + )} + + + {}} // Noop - history is managed via onHistoryUpdate + onHistoryUpdate={handleHistoryUpdate} + queryContext={queryContext} + refIds={vars} + errorContext={errorContext} // Will be added when error tracking is implemented + // schemas={schemas} // Will be added when schema extraction is implemented + /> + + + {suggestions.length > 0 && ( + + + + )} +
+ ); + + const renderSQLEditor = (width?: number, height?: number) => ( <>
-
- - - {shouldShowViewExplanation ? ( - - ) : ( - - )} - - - {}} // Noop - history is managed via onHistoryUpdate - onHistoryUpdate={handleHistoryUpdate} - queryContext={queryContext} - refIds={vars} - errorContext={errorContext} // Will be added when error tracking is implemented - // schemas={schemas} // Will be added when schema extraction is implemented - /> - - - {suggestions.length > 0 && ( - - - - )} -
- + {renderSQLButtons()}
+ > + {({ formatQuery }) => renderToolbox(formatQuery)} +
- <> - - - - - - - + + + + + + + + ); + + const renderStandaloneEditor = () => ( + + {({ width, height }) => ( + + {({ formatQuery }) => renderToolbox(formatQuery)} + + )} + + ); + + return ( + <> + {renderSQLEditor()} + {isExpanded && ( + setIsExpanded(false)} + > + {renderStandaloneEditor()} + + )} ); }; @@ -267,6 +345,14 @@ const getStyles = (theme: GrafanaTheme2) => ({ overflow: 'auto', minHeight: '100px', }), + modal: css({ + width: '95vw', + height: '95vh', + }), + modalContent: css({ + height: '100%', + paddingTop: 0, + }), // This is NOT ideal. The alternative is to expose SQL buttons as a separate component, // Then consume them in ExpressionQueryEditor. This requires a lot of refactoring and // can be prioritized later. diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 13445a18190..646a89bb698 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7475,6 +7475,13 @@ "tooltip-title": "Math operator", "tooltip-trigger": "Expression" }, + "query-toolbox": { + "tooltip-collapse-editor": "Collapse editor", + "tooltip-copy-query": "Copy query", + "tooltip-expand-editor": "Expand editor", + "tooltip-format-query": "Format query", + "tooltip-run-query": "Hit ctrl/cmd+enter to run query" + }, "reduce": { "label-function": "Function", "label-input": "Input", @@ -7491,6 +7498,8 @@ "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, "sql-expr": { + "button-run-query": "Run query", + "modal-title": "SQL Editor", "tooltip-experimental": "SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team." }, "threshold": {