- {renderSQLButtons()}
-
(
+
+
+ {isSchemasFeatureEnabled && (
+
);
- const renderStandaloneEditor = () => (
-
- {({ width, height }) => (
-
- {({ formatQuery }) => renderToolbox(formatQuery)}
-
- )}
-
+ const renderSQLEditor = () => (
+
+ {renderButtons()}
+ {renderMainContent()}
+
);
return (
- <>
- {renderSQLEditor()}
- {isExpanded && (
-
setIsExpanded(false)}
- >
- {renderStandaloneEditor()}
-
- )}
- >
+
+
+ {renderSQLEditor()}
+
+
+
+
+
+
+
+
);
};
-const getStyles = (theme: GrafanaTheme2, editorHeight: number) => ({
- sqlContainer: css({
- display: 'grid',
- gap: theme.spacing(1),
- gridTemplateRows: 'auto 1fr',
- gridTemplateAreas: `
- "buttons"
- "content"
- `,
+const getStyles = (theme: GrafanaTheme2) => ({
+ mainContainer: css({
+ marginTop: theme.spacing(0.5),
}),
-
contentContainer: css({
- gridArea: 'content',
+ minHeight: '250px',
+ height: '100%',
+ resize: 'vertical',
+ overflow: 'hidden',
+
display: 'grid',
- gap: theme.spacing(1),
gridTemplateColumns: '1fr 0fr',
gridTemplateAreas: '"editor schema"',
[theme.transitions.handleMotion('no-preference')]: {
@@ -408,67 +325,22 @@ const getStyles = (theme: GrafanaTheme2, editorHeight: number) => ({
}),
contentContainerWithSchema: css({
gridTemplateColumns: '1fr 1fr',
+ gap: theme.spacing(1),
}),
editorContainer: css({
gridArea: 'editor',
- height: editorHeight, // Use dynamic height from ResizeObserver
- resize: 'vertical',
- 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.
- sqlButtons: css({
- gridArea: 'buttons',
- justifySelf: 'end',
- transform: `translateY(${theme.spacing(-4)})`,
- marginBottom: theme.spacing(-4), // Prevent affecting editor position
- zIndex: 10, // Ensure buttons appear above other elements
- position: 'relative', // Required for z-index to work
- display: 'flex',
- alignItems: 'center',
- gap: theme.spacing(1),
+ width: '100%',
+ overflow: 'auto',
}),
schemaInspector: css({
gridArea: 'schema',
- height: editorHeight,
+ height: '100%',
overflow: 'hidden',
minWidth: 0,
- }),
- schemaInspectorOpen: css({
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
- schemaFields: css({
- display: 'flex',
- flexWrap: 'wrap',
- gap: theme.spacing(1),
- padding: theme.spacing(1),
- maxHeight: '120px',
- overflowY: 'auto',
- }),
- fieldItem: css({
- display: 'flex',
- alignItems: 'center',
- gap: theme.spacing(0.5),
- padding: theme.spacing(1),
- backgroundColor: theme.colors.background.secondary,
- borderRadius: theme.shape.radius.default,
- border: `1px solid ${theme.colors.border.weak}`,
- fontSize: theme.typography.bodySmall.fontSize,
- }),
- responseContainer: css({
- padding: theme.spacing(2),
- }),
});
async function fetchFields(identifier: TableIdentifier, queries: DataQuery[]) {
diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx
new file mode 100644
index 00000000000..f95128b5af6
--- /dev/null
+++ b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx
@@ -0,0 +1,88 @@
+import { render, screen } from 'test/test-utils';
+
+import { SqlExprContextValue, SqlExprProvider, useSqlExprContext } from './SqlExprContext';
+
+describe('SqlExprContext', () => {
+ const mockContextValue: SqlExprContextValue = {
+ explanation: 'Test explanation',
+ isExplanationOpen: false,
+ shouldShowViewExplanation: false,
+ handleExplain: jest.fn(),
+ handleOpenExplanation: jest.fn(),
+ handleCloseExplanation: jest.fn(),
+ suggestions: ['suggestion1', 'suggestion2'],
+ isDrawerOpen: false,
+ handleHistoryUpdate: jest.fn(),
+ handleApplySuggestion: jest.fn(),
+ handleOpenDrawer: jest.fn(),
+ handleCloseDrawer: jest.fn(),
+ };
+
+ describe('SqlExprProvider', () => {
+ it('renders children correctly', () => {
+ render(
+
+ Test Child
+
+ );
+
+ expect(screen.getByText('Test Child')).toBeInTheDocument();
+ });
+
+ it('provides context value to children', () => {
+ const TestConsumer = () => {
+ const context = useSqlExprContext();
+ return
{context.explanation}
;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Test explanation')).toBeInTheDocument();
+ });
+ });
+
+ describe('useSqlExprContext', () => {
+ it('throws error when used outside provider', () => {
+ const TestComponent = () => {
+ useSqlExprContext();
+ return
Should not render
;
+ };
+
+ // Suppress console.error for this test
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => {
+ render(
);
+ }).toThrow('useSqlExprContext must be used within SqlExprProvider');
+
+ consoleSpy.mockRestore();
+ });
+
+ it('returns context value when used inside provider', () => {
+ const TestComponent = () => {
+ const context = useSqlExprContext();
+ return (
+
+ Explanation: {context.explanation}
+ Suggestions: {context.suggestions.length}
+ Is Drawer Open: {context.isDrawerOpen.toString()}
+
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Explanation: Test explanation')).toBeInTheDocument();
+ expect(screen.getByText('Suggestions: 2')).toBeInTheDocument();
+ expect(screen.getByText('Is Drawer Open: false')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx
new file mode 100644
index 00000000000..e05ccff80b6
--- /dev/null
+++ b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx
@@ -0,0 +1,38 @@
+import { createContext, useContext, ReactNode } from 'react';
+
+export interface SqlExprContextValue {
+ // Explanations
+ explanation: string;
+ isExplanationOpen: boolean;
+ shouldShowViewExplanation: boolean;
+ handleExplain: (explanation: string) => void;
+ handleOpenExplanation: () => void;
+ handleCloseExplanation: () => void;
+
+ // Suggestions
+ suggestions: string[];
+ isDrawerOpen: boolean;
+ handleHistoryUpdate: (suggestions: string[]) => void;
+ handleApplySuggestion: (suggestion: string) => string;
+ handleOpenDrawer: () => void;
+ handleCloseDrawer: () => void;
+}
+
+const SqlExprContext = createContext
(null);
+
+export const useSqlExprContext = () => {
+ const context = useContext(SqlExprContext);
+ if (!context) {
+ throw new Error('useSqlExprContext must be used within SqlExprProvider');
+ }
+ return context;
+};
+
+interface SqlExprProviderProps {
+ children: ReactNode;
+ value: SqlExprContextValue;
+}
+
+export const SqlExprProvider = ({ children, value }: SqlExprProviderProps) => {
+ return {children};
+};
diff --git a/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx
new file mode 100644
index 00000000000..93e3285ed83
--- /dev/null
+++ b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx
@@ -0,0 +1,137 @@
+import { fireEvent, render, waitFor } from 'test/test-utils';
+
+import { SqlExprContextValue } from './SqlExprContext';
+import { SqlQueryActions, SqlQueryActionsProps } from './SqlQueryActions';
+
+jest.mock('@grafana/ui', () => ({
+ ...jest.requireActual('@grafana/ui'),
+ useStyles2: jest.fn().mockImplementation(() => ({})),
+}));
+
+// Mock lazy loaded GenAI components
+jest.mock('./GenAI/GenAISQLSuggestionsButton', () => ({
+ GenAISQLSuggestionsButton: ({ currentQuery, initialQuery }: { currentQuery: string; initialQuery: string }) => {
+ const text = !currentQuery || currentQuery === initialQuery ? 'Generate suggestion' : 'Improve query';
+ return {text}
;
+ },
+}));
+
+jest.mock('./GenAI/GenAISQLExplainButton', () => ({
+ GenAISQLExplainButton: () => Explain query
,
+}));
+
+jest.mock('./GenAI/SuggestionsDrawerButton', () => ({
+ SuggestionsDrawerButton: () => Suggestions Badge
,
+}));
+
+// Mock SqlExprContext
+const mockContextValue: SqlExprContextValue = {
+ handleOpenExplanation: jest.fn(),
+ shouldShowViewExplanation: false,
+ handleExplain: jest.fn(),
+ handleHistoryUpdate: jest.fn(),
+ handleOpenDrawer: jest.fn(),
+ suggestions: [],
+ explanation: '',
+ isExplanationOpen: false,
+ isDrawerOpen: false,
+ handleApplySuggestion: jest.fn(),
+ handleCloseDrawer: jest.fn(),
+ handleCloseExplanation: jest.fn(),
+};
+
+jest.mock('./SqlExprContext', () => ({
+ useSqlExprContext: () => mockContextValue,
+ SqlExprProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}));
+
+describe('SqlQueryActions', () => {
+ const defaultProps: SqlQueryActionsProps = {
+ executeQuery: jest.fn(),
+ currentQuery: `SELECT * FROM A LIMIT 10`,
+ queryContext: {},
+ refIds: ['A'],
+ initialQuery: `SELECT * FROM A LIMIT 10`,
+ errorContext: [],
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Reset mock context to default values
+ Object.assign(mockContextValue, {
+ handleOpenExplanation: jest.fn(),
+ shouldShowViewExplanation: false,
+ handleExplain: jest.fn(),
+ handleHistoryUpdate: jest.fn(),
+ handleOpenDrawer: jest.fn(),
+ suggestions: [],
+ explanation: '',
+ isExplanationOpen: false,
+ isDrawerOpen: false,
+ handleApplySuggestion: jest.fn(),
+ handleCloseDrawer: jest.fn(),
+ handleCloseExplanation: jest.fn(),
+ });
+ });
+
+ it('renders GenAI buttons with empty expression', async () => {
+ const customProps = { ...defaultProps, currentQuery: '' };
+ const { findByText } = render();
+ expect(await findByText('Generate suggestion')).toBeInTheDocument();
+ expect(await findByText('Explain query')).toBeInTheDocument();
+ });
+
+ it('renders GenAI buttons with non-empty expression', async () => {
+ const { findByText } = render();
+ expect(await findByText('Generate suggestion')).toBeInTheDocument();
+ expect(await findByText('Explain query')).toBeInTheDocument();
+ });
+
+ it('renders "Improve query" when currentQuery differs from initialQuery', async () => {
+ const customProps = {
+ ...defaultProps,
+ currentQuery: 'SELECT * FROM A WHERE value > 10',
+ };
+ const { findByText } = render();
+ expect(await findByText('Improve query')).toBeInTheDocument();
+ });
+
+ it('renders View explanation button when shouldShowViewExplanation is true', async () => {
+ mockContextValue.shouldShowViewExplanation = true;
+
+ const { findByText } = render();
+ expect(await findByText('View explanation')).toBeInTheDocument();
+ });
+
+ it('renders Explain query button when shouldShowViewExplanation is false', async () => {
+ mockContextValue.shouldShowViewExplanation = false;
+
+ const { findByText } = render();
+ expect(await findByText('Explain query')).toBeInTheDocument();
+ });
+
+ it('renders SuggestionsDrawerButton when there are suggestions', async () => {
+ mockContextValue.suggestions = ['suggestion1', 'suggestion2'];
+
+ const { findByTestId } = render();
+ expect(await findByTestId('suggestions-badge')).toBeInTheDocument();
+ });
+
+ it('does not render SuggestionsDrawerButton when there are no suggestions', async () => {
+ mockContextValue.suggestions = [];
+
+ const { queryByTestId } = render();
+ expect(await waitFor(() => queryByTestId('suggestions-badge'))).not.toBeInTheDocument();
+ });
+
+ it('calls handleOpenExplanation when View explanation is clicked', async () => {
+ const mockHandleOpen = jest.fn();
+ mockContextValue.shouldShowViewExplanation = true;
+ mockContextValue.handleOpenExplanation = mockHandleOpen;
+
+ const { findByText } = render();
+ const button = await findByText('View explanation');
+ fireEvent.click(button);
+ expect(mockHandleOpen).toHaveBeenCalled();
+ });
+});
diff --git a/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx
new file mode 100644
index 00000000000..a96a82a4e72
--- /dev/null
+++ b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx
@@ -0,0 +1,91 @@
+import { lazy, Suspense } from 'react';
+
+import { t, Trans } from '@grafana/i18n';
+import { Button, Stack } from '@grafana/ui';
+
+import { useSqlExprContext } from './SqlExprContext';
+
+// Lazy load the GenAI components to avoid circular dependencies
+const GenAISQLSuggestionsButton = lazy(() =>
+ import('./GenAI/GenAISQLSuggestionsButton').then((module) => ({
+ default: module.GenAISQLSuggestionsButton,
+ }))
+);
+
+const GenAISQLExplainButton = lazy(() =>
+ import('./GenAI/GenAISQLExplainButton').then((module) => ({
+ default: module.GenAISQLExplainButton,
+ }))
+);
+
+const SuggestionsDrawerButton = lazy(() =>
+ import('./GenAI/SuggestionsDrawerButton').then((module) => ({
+ default: module.SuggestionsDrawerButton,
+ }))
+);
+
+export interface SqlQueryActionsProps {
+ executeQuery: () => void;
+ currentQuery: string;
+ queryContext: Record;
+ refIds: string[];
+ initialQuery: string;
+ errorContext: string[];
+}
+
+export const SqlQueryActions = ({
+ executeQuery,
+ currentQuery,
+ queryContext,
+ refIds,
+ initialQuery,
+ errorContext,
+}: SqlQueryActionsProps) => {
+ const {
+ handleOpenExplanation,
+ shouldShowViewExplanation,
+ handleExplain,
+ handleHistoryUpdate,
+ handleOpenDrawer,
+ suggestions,
+ } = useSqlExprContext();
+ return (
+
+
+
+ {shouldShowViewExplanation ? (
+
+ ) : (
+
+ )}
+
+
+ {}} // Noop - history is managed via onHistoryUpdate
+ onHistoryUpdate={handleHistoryUpdate}
+ queryContext={queryContext}
+ refIds={refIds}
+ errorContext={errorContext} // Will be added when error tracking is implemented
+ // schemas={schemas} // Will be added when schema extraction is implemented
+ />
+
+ {suggestions.length > 0 && (
+
+
+
+ )}
+
+ );
+};
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index d97ce1128ba..807d6fbfa4b 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -7872,23 +7872,18 @@
"label-upsample": "Upsample",
"tooltip-s-m-h": "10s, 1m, 30m, 1h"
},
- "schema-inspector-panel": {
- "aria-label-close-schema-inspector": "Close schema inspector"
- },
"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."
},
"sql-schema": {
- "close-schema-inspector": "Close schema inspector",
"error-title": "Error",
- "inspect-button": "Inspect schema",
"loading": "Loading schema information...",
"no-data-title": "No schema information available",
"no-fields-desc": "This query returned no schema information.",
"no-fields-title": "No schema information",
- "query-error-title": "Query error"
+ "query-error-title": "Query error",
+ "schema-inspector": "Schema inspector"
},
"threshold": {
"label-input": "Input"