From 6daa7ff72911e5fc3938adf500db7bafec5f5ef6 Mon Sep 17 00:00:00 2001 From: Sean Griffin Date: Fri, 19 Dec 2025 16:05:46 -0500 Subject: [PATCH] Clean up Schema Inspector feature code (#115514) Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> --- .../SchemaInspector/SchemaInspectorPanel.tsx | 80 ++-- .../SqlExpressions/SqlExpr.test.tsx | 90 +---- .../components/SqlExpressions/SqlExpr.tsx | 344 ++++++------------ .../SqlExpressions/SqlExprContext.test.tsx | 88 +++++ .../SqlExpressions/SqlExprContext.tsx | 38 ++ .../SqlExpressions/SqlQueryActions.test.tsx | 137 +++++++ .../SqlExpressions/SqlQueryActions.tsx | 91 +++++ public/locales/en-US/grafana.json | 9 +- 8 files changed, 498 insertions(+), 379 deletions(-) create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx diff --git a/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx b/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx index c84bf0d4223..a065bbc779f 100644 --- a/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx @@ -1,25 +1,24 @@ import { css } from '@emotion/css'; -import { useState, useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; import { - Stack, - Tab, - TabsBar, - TabContent, - Icon, + Alert, Badge, - Text, - useStyles2, + Icon, InteractiveTable, ScrollContainer, - Alert, Spinner, - IconButton, + Stack, + Tab, + TabContent, + TabsBar, + Text, + useStyles2, } from '@grafana/ui'; -import { SQLSchemas, SQLSchemaField, SQLSchemaData } from '../hooks/useSQLSchemas'; +import { SQLSchemaData, SQLSchemaField, SQLSchemas } from '../hooks/useSQLSchemas'; import { getFieldTypeIcon } from './utils'; @@ -33,10 +32,9 @@ interface SchemaInspectorPanelProps { schemas: SQLSchemas | null; loading: boolean; error: Error | null; - onClose: () => void; } -export const SchemaInspectorPanel = ({ schemas, loading, error, onClose }: SchemaInspectorPanelProps) => { +export const SchemaInspectorPanel = ({ schemas, loading, error }: SchemaInspectorPanelProps) => { const styles = useStyles2(getStyles); const schemaResponse: SQLSchemas = schemas ?? {}; @@ -192,32 +190,21 @@ export const SchemaInspectorPanel = ({ schemas, loading, error, onClose }: Schem }; return ( -
-
- {refIds.length > 0 && ( - - {refIds.map((refId) => ( - setSelectedTab(refId)} - /> - ))} - - )} - -
+ <> + {refIds.length > 0 && ( + + {refIds.map((refId) => ( + setSelectedTab(refId)} + /> + ))} + + )} {renderContent()} -
+ ); }; @@ -225,21 +212,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ schemaInfoContainer: css({ padding: theme.spacing(1), }), - schemaInspector: css({ - height: '100%', - display: 'flex', - flexDirection: 'column', - }), - // Unfortunate hack to get the close button to align with the tabs since we need to - // override the default styles of the TabsBar component. - tabsBarWrapper: css({ - flexShrink: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - width: '100%', - padding: `0 ${theme.spacing(1)}`, - }), tableCell: css({ fontSize: theme.typography.bodySmall.fontSize, fontWeight: theme.typography.fontWeightMedium, @@ -247,10 +219,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), tableContainer: css({ margin: theme.spacing(1), - flex: 1, overflowY: 'auto', overflowX: 'auto', - minHeight: 0, // Allow flex child to shrink border: `1px solid ${theme.colors.border.medium}`, borderRadius: theme.shape.radius.default, backgroundColor: theme.colors.background.primary, diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx index b7acc586447..9e2cfd50b75 100644 --- a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor, fireEvent, act, testWithFeatureToggles } from 'test/test-utils'; +import { act, fireEvent, render, testWithFeatureToggles } from 'test/test-utils'; import { ExpressionQuery, ExpressionQueryType } from '../../types'; @@ -127,78 +127,6 @@ describe('SqlExpr with GenAI features', () => { queries: [], }; - it('renders GenAI buttons with empty expression', async () => { - const customProps = { ...defaultProps, query: { ...defaultProps.query, expression: '' } }; - 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('Improve query')).toBeInTheDocument(); - expect(await findByText('Explain query')).toBeInTheDocument(); - }); - - it('renders "Improve query" when currentQuery differs from initialQuery', async () => { - const customProps = { - ...defaultProps, - query: { ...defaultProps.query, expression: '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 () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - useSQLExplanations.mockImplementation((currentExpression: string) => ({ - shouldShowViewExplanation: true, - })); - - const { findByText } = render(); - expect(await findByText('View explanation')).toBeInTheDocument(); - }); - - it('renders Explain query button when shouldShowViewExplanation is false', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - useSQLExplanations.mockImplementation((currentExpression: string) => ({ - shouldShowViewExplanation: false, - })); - - const { findByText } = render(); - expect(await findByText('Explain query')).toBeInTheDocument(); - }); - - it('renders SuggestionsDrawerButton when there are suggestions', async () => { - const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); - useSQLSuggestions.mockImplementation(() => ({ suggestions: ['suggestion1', 'suggestion2'] })); - - const { findByTestId } = render(); - expect(await findByTestId('suggestions-badge')).toBeInTheDocument(); - }); - - it('does not render SuggestionsDrawerButton when there are no suggestions', async () => { - const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); - useSQLSuggestions.mockImplementation(() => ({ suggestions: [] })); - - const { queryByTestId } = render(); - expect(await waitFor(() => queryByTestId('suggestions-badge'))).not.toBeInTheDocument(); - }); - - it('calls handleOpenExplanation when View explanation is clicked', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - const mockHandleOpen = jest.fn(); - useSQLExplanations.mockImplementation(() => ({ - shouldShowViewExplanation: true, - handleOpenExplanation: mockHandleOpen, - })); - - const { findByText } = render(); - const button = await findByText('View explanation'); - fireEvent.click(button); - expect(mockHandleOpen).toHaveBeenCalled(); - }); - it('renders suggestions drawer when isDrawerOpen is true', async () => { const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); useSQLSuggestions.mockImplementation(() => ({ @@ -245,26 +173,26 @@ describe('Schema Inspector feature toggle', () => { }); it('closes panel and shows reopen button when close button clicked', async () => { - const { queryByText, getByLabelText, findByText } = render(); + const { queryByText, getByText, findByText } = render(); expect(queryByText('No schema information available')).toBeInTheDocument(); - const closeButton = getByLabelText('Close schema inspector'); + const closeButton = getByText('Schema inspector'); await act(async () => fireEvent.click(closeButton)); expect(queryByText('No schema information available')).not.toBeInTheDocument(); - expect(await findByText('Inspect schema')).toBeInTheDocument(); + expect(await findByText('Schema inspector')).toBeInTheDocument(); }); - it('reopens panel when inspect schema button clicked after closing', async () => { - const { queryByText, getByLabelText, getByText } = render(); + it('reopens panel when Open schema inspector button clicked after closing', async () => { + const { queryByText, getByText } = render(); - const closeButton = getByLabelText('Close schema inspector'); + const closeButton = getByText('Schema inspector'); await act(async () => fireEvent.click(closeButton)); expect(queryByText('No schema information available')).not.toBeInTheDocument(); - const reopenButton = getByText('Inspect schema'); + const reopenButton = getByText('Schema inspector'); await act(async () => fireEvent.click(reopenButton)); expect(queryByText('No schema information available')).toBeInTheDocument(); @@ -300,7 +228,7 @@ describe('Schema Inspector feature toggle', () => { it('does not render panel or button', () => { const { queryByText } = render(); - expect(queryByText('Inspect schema')).not.toBeInTheDocument(); + expect(queryByText('Schema inspector')).not.toBeInTheDocument(); expect(queryByText('No schema information available')).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx index 361d748b716..a59b46b232b 100644 --- a/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx @@ -1,15 +1,15 @@ import { css, cx } from '@emotion/css'; -import { useMemo, useRef, useEffect, useState, lazy, Suspense, useCallback } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useMeasure } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; -import { t, Trans } from '@grafana/i18n'; -import { SQLEditor, CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/plugin-ui'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { CompletionItemKind, LanguageDefinition, SQLEditor, TableIdentifier } from '@grafana/plugin-ui'; import { reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema/dist/esm/index'; import { formatSQL } from '@grafana/sql'; -import { useStyles2, Stack, Button, Modal } from '@grafana/ui'; +import { Button, Stack, useStyles2 } from '@grafana/ui'; import { ExpressionQueryEditorProps } from '../../ExpressionQueryEditor'; import { SqlExpressionQuery } from '../../types'; @@ -20,27 +20,10 @@ import { getSqlCompletionProvider } from './CompletionProvider/sqlCompletionProv import { useSQLExplanations } from './GenAI/hooks/useSQLExplanations'; import { useSQLSuggestions } from './GenAI/hooks/useSQLSuggestions'; import { SchemaInspectorPanel } from './SchemaInspector/SchemaInspectorPanel'; +import { SqlExprContextValue, SqlExprProvider } from './SqlExprContext'; +import { SqlQueryActions } from './SqlQueryActions'; import { useSQLSchemas } from './hooks/useSQLSchemas'; -// 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, - })) -); - const GenAISuggestionsDrawer = lazy(() => import('./GenAI/GenAISuggestionsDrawer').then((module) => ({ default: module.GenAISuggestionsDrawer, @@ -55,7 +38,6 @@ const GenAIExplanationDrawer = lazy(() => // Account for Monaco editor's border to prevent clipping const EDITOR_BORDER_ADJUSTMENT = 2; // 1px border on top and bottom -const EDITOR_HEIGHT = 300; export interface SqlExprProps { refIds: Array>; @@ -93,14 +75,10 @@ FROM LIMIT 10`; - const [dimensions, setDimensions] = useState({ height: 0 }); - const styles = useStyles2((theme) => getStyles(theme, dimensions.height || EDITOR_HEIGHT)); - const containerRef = useRef(null); const [toolboxRef, toolboxMeasure] = useMeasure(); - const [isExpanded, setIsExpanded] = useState(false); const [isSchemaInspectorOpen, setIsSchemaInspectorOpen] = useState(true); - - const { handleApplySuggestion, handleHistoryUpdate, handleCloseDrawer, handleOpenDrawer, isDrawerOpen, suggestions } = + const styles = useStyles2((theme) => getStyles(theme)); + const { handleApplySuggestion, handleCloseDrawer, handleHistoryUpdate, handleOpenDrawer, isDrawerOpen, suggestions } = useSQLSuggestions(); const { @@ -195,21 +173,6 @@ LIMIT } }, [onRunQuery, refetchSchemas, isSchemaInspectorOpen]); - // Set up resize observer to handle container resizing - useEffect(() => { - if (!containerRef.current) { - return; - } - - const resizeObserver = new ResizeObserver((entries) => { - const { height } = entries[0].contentRect; - setDimensions({ height }); - }); - - resizeObserver.observe(containerRef.current); - return () => resizeObserver.disconnect(); - }, []); - useEffect(() => { // Call the onChange method once so we have access to the initial query in consuming components // But only if expression is empty @@ -236,168 +199,122 @@ LIMIT return () => document.removeEventListener('keydown', handleKeyDown, true); }, [executeQuery]); - const renderToolbox = (formatQuery: () => void) => ( -
- -
- ); + const contextValue: SqlExprContextValue = { + // Explanations + explanation, + isExplanationOpen, + shouldShowViewExplanation, + handleExplain, + handleOpenExplanation, + handleCloseExplanation, + // Suggestions + suggestions, + isDrawerOpen, + handleHistoryUpdate, + handleApplySuggestion, + handleOpenDrawer, + handleCloseDrawer, + }; - const renderSQLButtons = () => ( -
- - {isSchemasFeatureEnabled && !isSchemaInspectorOpen && ( - - )} - - - {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) => ( - <> -
- {renderSQLButtons()} -
( + + + {isSchemasFeatureEnabled && ( + + )} + + ); + + const renderMainContent = () => ( +
+
+ + {({ width, height }) => ( - {({ formatQuery }) => renderToolbox(formatQuery)} + {({ formatQuery }) => ( +
+ +
+ )}
-
- {isSchemaInspectorOpen && isSchemasFeatureEnabled && ( -
- setIsSchemaInspectorOpen(false)} - /> -
)} -
+
- - - - - - - + {isSchemaInspectorOpen && 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"