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:
co-authored by
Kristina Durivage
parent
ee9c73f866
commit
73a1320334
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user