SQL Expressions: Add editor toolbox (#109298)

* init: feature work

* chore: i18n

* feat: add run query + keyboard shortcuts

* chore: i18n

* chore: initial query formatted

* chore: resolve some issues

* chore: a bit of refactor to clean it up

---------

Co-authored-by: Kristina Durivage <kristina.durivage@grafana.com>
This commit is contained in:
Alex Spencer
2025-08-13 11:43:01 -07:00
committed by GitHub
co-authored by Kristina Durivage
parent ee9c73f866
commit 73a1320334
4 changed files with 279 additions and 71 deletions
@@ -118,7 +118,16 @@ export function ExpressionQueryEditor(props: ExpressionQueryEditorProps) {
return <Threshold onChange={onChange} query={query} labelWidth={labelWidth} refIds={refIds} />;
case ExpressionQueryType.sql:
return <SqlExpr onChange={onChange} query={query} refIds={refIds} queries={queries} metadata={props} />;
return (
<SqlExpr
onChange={onChange}
query={query}
refIds={refIds}
queries={queries}
metadata={props}
onRunQuery={onRunQuery}
/>
);
}
};
@@ -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<HTMLButtonElement>(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 (
<div className={styles.container}>
<Stack alignItems="center" direction="row" gap={1}>
{onFormatCode && (
<IconButton
onClick={onFormatCode}
name="brackets-curly"
size="xs"
tooltip={t('expressions.query-toolbox.tooltip-format-query', 'Format query')}
/>
)}
{onExpand && (
<IconButton
onClick={() => 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 && (
<InlineToast placement="top" referenceElement={buttonRef.current}>
{copiedText}
</InlineToast>
)}
<IconButton
name={showCopySuccess ? 'check' : 'copy'}
onClick={copyTextCallback}
ref={buttonRef}
size="xs"
tooltip={t('expressions.query-toolbox.tooltip-copy-query', 'Copy query')}
variant={showCopySuccess ? 'primary' : 'secondary'}
/>
{!isExpanded && (
<Tooltip content={t('expressions.query-toolbox.tooltip-run-query', 'Hit ctrl/cmd+enter to run query')}>
<Icon name="keyboard" />
</Tooltip>
)}
</Stack>
</div>
);
};
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,
}),
});
@@ -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<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ height: 0 });
const [toolboxRef, toolboxMeasure] = useMeasure<HTMLDivElement>();
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) => (
<div ref={toolboxRef}>
<QueryToolbox query={query} onFormatCode={formatQuery} onExpand={setIsExpanded} isExpanded={isExpanded} />
</div>
);
const renderSQLButtons = () => (
<div className={styles.sqlButtons}>
<Stack direction="row" gap={1} alignItems="center" justifyContent="end">
<Button icon="play" onClick={executeQuery} size="sm">
{t('expressions.sql-expr.button-run-query', 'Run query')}
</Button>
<Suspense fallback={null}>
{shouldShowViewExplanation ? (
<Button
fill="outline"
icon="gf-movepane-right"
onClick={handleOpenExplanation}
size="sm"
variant="secondary"
>
<Trans i18nKey="sql-expressions.view-explanation">View explanation</Trans>
</Button>
) : (
<GenAISQLExplainButton
currentQuery={query.expression || ''}
onExplain={handleExplain}
queryContext={queryContext}
refIds={vars}
// schemas={schemas} // Will be added when schema extraction is implemented
/>
)}
</Suspense>
<Suspense fallback={null}>
<GenAISQLSuggestionsButton
currentQuery={query.expression || ''}
initialQuery={initialQuery}
onGenerate={() => {}} // 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
/>
</Suspense>
</Stack>
{suggestions.length > 0 && (
<Suspense fallback={null}>
<SuggestionsDrawerButton handleOpenDrawer={handleOpenDrawer} suggestions={suggestions} />
</Suspense>
)}
</div>
);
const renderSQLEditor = (width?: number, height?: number) => (
<>
<div className={styles.sqlContainer}>
<div className={styles.sqlButtons}>
<Stack direction="row" gap={1} alignItems="center" justifyContent="end">
<Suspense fallback={null}>
{shouldShowViewExplanation ? (
<Button
fill="outline"
icon="gf-movepane-right"
onClick={handleOpenExplanation}
size="sm"
variant="secondary"
>
<Trans i18nKey="sql-expressions.view-explanation">View explanation</Trans>
</Button>
) : (
<GenAISQLExplainButton
currentQuery={query.expression || ''}
onExplain={handleExplain}
queryContext={queryContext}
refIds={vars}
// schemas={schemas} // Will be added when schema extraction is implemented
/>
)}
</Suspense>
<Suspense fallback={null}>
<GenAISQLSuggestionsButton
currentQuery={query.expression || ''}
initialQuery={initialQuery}
onGenerate={() => {}} // 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
/>
</Suspense>
</Stack>
{suggestions.length > 0 && (
<Suspense fallback={null}>
<SuggestionsDrawerButton handleOpenDrawer={handleOpenDrawer} suggestions={suggestions} />
</Suspense>
)}
</div>
{renderSQLButtons()}
<div ref={containerRef} className={styles.editorContainer}>
<SQLEditor
query={query.expression || initialQuery}
onChange={onEditorChange}
height={dimensions.height - EDITOR_BORDER_ADJUSTMENT}
width={width}
height={height ?? dimensions.height - EDITOR_BORDER_ADJUSTMENT - toolboxMeasure.height}
language={EDITOR_LANGUAGE_DEFINITION}
/>
>
{({ formatQuery }) => renderToolbox(formatQuery)}
</SQLEditor>
</div>
</div>
<>
<Suspense fallback={null}>
<GenAISuggestionsDrawer
isOpen={isDrawerOpen}
onApplySuggestion={onApplySuggestion}
onClose={handleCloseDrawer}
suggestions={suggestions}
/>
</Suspense>
<Suspense fallback={null}>
<GenAIExplanationDrawer
isOpen={isExplanationOpen}
onClose={handleCloseExplanation}
explanation={explanation}
/>
</Suspense>
</>
<Suspense fallback={null}>
<GenAISuggestionsDrawer
isOpen={isDrawerOpen}
onApplySuggestion={onApplySuggestion}
onClose={handleCloseDrawer}
suggestions={suggestions}
/>
</Suspense>
<Suspense fallback={null}>
<GenAIExplanationDrawer isOpen={isExplanationOpen} onClose={handleCloseExplanation} explanation={explanation} />
</Suspense>
</>
);
const renderStandaloneEditor = () => (
<AutoSizer>
{({ width, height }) => (
<SQLEditor
query={query.expression || initialQuery}
onChange={onEditorChange}
width={width}
height={height ? height - EDITOR_BORDER_ADJUSTMENT - toolboxMeasure.height : undefined}
language={EDITOR_LANGUAGE_DEFINITION}
>
{({ formatQuery }) => renderToolbox(formatQuery)}
</SQLEditor>
)}
</AutoSizer>
);
return (
<>
{renderSQLEditor()}
{isExpanded && (
<Modal
title={t('expressions.sql-expr.modal-title', 'SQL Editor')}
closeOnBackdropClick={false}
closeOnEscape={false}
className={styles.modal}
contentClassName={styles.modalContent}
isOpen={isExpanded}
onDismiss={() => setIsExpanded(false)}
>
{renderStandaloneEditor()}
</Modal>
)}
</>
);
};
@@ -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.
+9
View File
@@ -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": {