Clean up Schema Inspector feature code (#115514)

Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com>
This commit is contained in:
Sean Griffin
2025-12-19 16:05:46 -05:00
committed by GitHub
co-authored by Alex Spencer
parent 8cfac85b48
commit 6daa7ff729
8 changed files with 498 additions and 379 deletions
@@ -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 (
<div className={styles.schemaInspector}>
<div className={styles.tabsBarWrapper}>
{refIds.length > 0 && (
<TabsBar>
{refIds.map((refId) => (
<Tab
key={refId}
label={refId}
active={activeSchemaTab === refId}
onChangeTab={() => setSelectedTab(refId)}
/>
))}
</TabsBar>
)}
<IconButton
name="times"
onClick={onClose}
tooltip={t('expressions.sql-schema.close-schema-inspector', 'Close schema inspector')}
aria-label={t(
'expressions.schema-inspector-panel.aria-label-close-schema-inspector',
'Close schema inspector'
)}
/>
</div>
<>
{refIds.length > 0 && (
<TabsBar>
{refIds.map((refId) => (
<Tab
key={refId}
label={refId}
active={activeSchemaTab === refId}
onChangeTab={() => setSelectedTab(refId)}
/>
))}
</TabsBar>
)}
{renderContent()}
</div>
</>
);
};
@@ -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,
@@ -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(<SqlExpr {...customProps} />);
expect(await findByText('Generate suggestion')).toBeInTheDocument();
expect(await findByText('Explain query')).toBeInTheDocument();
});
it('renders GenAI buttons with non-empty expression', async () => {
const { findByText } = render(<SqlExpr {...defaultProps} />);
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(<SqlExpr {...customProps} />);
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(<SqlExpr {...defaultProps} />);
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(<SqlExpr {...defaultProps} />);
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(<SqlExpr {...defaultProps} />);
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(<SqlExpr {...defaultProps} />);
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(<SqlExpr {...defaultProps} />);
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(<SqlExpr {...defaultProps} />);
const { queryByText, getByText, findByText } = render(<SqlExpr {...defaultProps} />);
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(<SqlExpr {...defaultProps} />);
it('reopens panel when Open schema inspector button clicked after closing', async () => {
const { queryByText, getByText } = render(<SqlExpr {...defaultProps} />);
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(<SqlExpr {...defaultProps} />);
expect(queryByText('Inspect schema')).not.toBeInTheDocument();
expect(queryByText('Schema inspector')).not.toBeInTheDocument();
expect(queryByText('No schema information available')).not.toBeInTheDocument();
});
});
@@ -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<SelectableValue<string>>;
@@ -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<HTMLDivElement>(null);
const [toolboxRef, toolboxMeasure] = useMeasure<HTMLDivElement>();
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) => (
<div ref={toolboxRef}>
<QueryToolbox query={query} onFormatCode={formatQuery} onExpand={setIsExpanded} isExpanded={isExpanded} />
</div>
);
const contextValue: SqlExprContextValue = {
// Explanations
explanation,
isExplanationOpen,
shouldShowViewExplanation,
handleExplain,
handleOpenExplanation,
handleCloseExplanation,
// Suggestions
suggestions,
isDrawerOpen,
handleHistoryUpdate,
handleApplySuggestion,
handleOpenDrawer,
handleCloseDrawer,
};
const renderSQLButtons = () => (
<div className={styles.sqlButtons}>
<Stack direction="row" gap={1} alignItems="center" justifyContent="end">
{isSchemasFeatureEnabled && !isSchemaInspectorOpen && (
<Button
icon="table-expand-all"
onClick={() => setIsSchemaInspectorOpen(true)}
size="sm"
variant="secondary"
fill="outline"
>
<Trans i18nKey="expressions.sql-schema.inspect-button">Inspect schema</Trans>
</Button>
)}
<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}>
{renderSQLButtons()}
<div
className={cx(styles.contentContainer, {
[styles.contentContainerWithSchema]: isSchemaInspectorOpen && isSchemasFeatureEnabled,
})}
const renderButtons = () => (
<Stack direction="row" alignItems="center" justifyContent="space-between" wrap>
<SqlQueryActions
executeQuery={executeQuery}
currentQuery={query.expression || ''}
queryContext={queryContext}
refIds={vars}
initialQuery={initialQuery}
errorContext={errorContext}
/>
{isSchemasFeatureEnabled && (
<Button
icon={isSchemaInspectorOpen ? 'eye' : 'eye-slash'}
onClick={() => setIsSchemaInspectorOpen(!isSchemaInspectorOpen)}
size="sm"
variant="secondary"
fill="outline"
>
<div ref={containerRef} className={styles.editorContainer}>
<Trans i18nKey="expressions.sql-schema.schema-inspector">Schema inspector</Trans>
</Button>
)}
</Stack>
);
const renderMainContent = () => (
<div
className={cx(styles.contentContainer, {
[styles.contentContainerWithSchema]: isSchemaInspectorOpen && isSchemasFeatureEnabled,
})}
>
<div className={styles.editorContainer}>
<AutoSizer>
{({ width, height }) => (
<SQLEditor
query={query.expression || initialQuery}
onChange={onEditorChange}
width={width}
height={height ?? dimensions.height - EDITOR_BORDER_ADJUSTMENT - toolboxMeasure.height}
language={EDITOR_LANGUAGE_DEFINITION}
width={width}
height={height - EDITOR_BORDER_ADJUSTMENT - toolboxMeasure.height}
>
{({ formatQuery }) => renderToolbox(formatQuery)}
{({ formatQuery }) => (
<div ref={toolboxRef}>
<QueryToolbox query={query} onFormatCode={formatQuery} />
</div>
)}
</SQLEditor>
</div>
{isSchemaInspectorOpen && isSchemasFeatureEnabled && (
<div className={`${styles.schemaInspector} ${isSchemaInspectorOpen ? styles.schemaInspectorOpen : ''}`}>
<SchemaInspectorPanel
schemas={schemas?.sqlSchemas ?? null}
loading={schemasLoading}
error={schemasError}
onClose={() => setIsSchemaInspectorOpen(false)}
/>
</div>
)}
</div>
</AutoSizer>
</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>
</>
{isSchemaInspectorOpen && isSchemasFeatureEnabled && (
<div className={styles.schemaInspector}>
<SchemaInspectorPanel schemas={schemas?.sqlSchemas ?? null} loading={schemasLoading} error={schemasError} />
</div>
)}
</div>
);
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>
const renderSQLEditor = () => (
<Stack direction="column" gap={1}>
{renderButtons()}
{renderMainContent()}
</Stack>
);
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>
)}
</>
<SqlExprProvider value={contextValue}>
<div className={styles.mainContainer}>
{renderSQLEditor()}
<Suspense fallback={null}>
<GenAISuggestionsDrawer
isOpen={isDrawerOpen}
onApplySuggestion={onApplySuggestion}
onClose={handleCloseDrawer}
suggestions={suggestions}
/>
</Suspense>
<Suspense fallback={null}>
<GenAIExplanationDrawer
isOpen={isExplanationOpen}
onClose={handleCloseExplanation}
explanation={explanation}
/>
</Suspense>
</div>
</SqlExprProvider>
);
};
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[]) {
@@ -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(
<SqlExprProvider value={mockContextValue}>
<div>Test Child</div>
</SqlExprProvider>
);
expect(screen.getByText('Test Child')).toBeInTheDocument();
});
it('provides context value to children', () => {
const TestConsumer = () => {
const context = useSqlExprContext();
return <div>{context.explanation}</div>;
};
render(
<SqlExprProvider value={mockContextValue}>
<TestConsumer />
</SqlExprProvider>
);
expect(screen.getByText('Test explanation')).toBeInTheDocument();
});
});
describe('useSqlExprContext', () => {
it('throws error when used outside provider', () => {
const TestComponent = () => {
useSqlExprContext();
return <div>Should not render</div>;
};
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
render(<TestComponent />);
}).toThrow('useSqlExprContext must be used within SqlExprProvider');
consoleSpy.mockRestore();
});
it('returns context value when used inside provider', () => {
const TestComponent = () => {
const context = useSqlExprContext();
return (
<div>
<span>Explanation: {context.explanation}</span>
<span>Suggestions: {context.suggestions.length}</span>
<span>Is Drawer Open: {context.isDrawerOpen.toString()}</span>
</div>
);
};
render(
<SqlExprProvider value={mockContextValue}>
<TestComponent />
</SqlExprProvider>
);
expect(screen.getByText('Explanation: Test explanation')).toBeInTheDocument();
expect(screen.getByText('Suggestions: 2')).toBeInTheDocument();
expect(screen.getByText('Is Drawer Open: false')).toBeInTheDocument();
});
});
});
@@ -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<SqlExprContextValue | null>(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 <SqlExprContext.Provider value={value}>{children}</SqlExprContext.Provider>;
};
@@ -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 <div data-testid="suggestions-button">{text}</div>;
},
}));
jest.mock('./GenAI/GenAISQLExplainButton', () => ({
GenAISQLExplainButton: () => <div data-testid="explain-button">Explain query</div>,
}));
jest.mock('./GenAI/SuggestionsDrawerButton', () => ({
SuggestionsDrawerButton: () => <div data-testid="suggestions-badge">Suggestions Badge</div>,
}));
// 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(<SqlQueryActions {...customProps} />);
expect(await findByText('Generate suggestion')).toBeInTheDocument();
expect(await findByText('Explain query')).toBeInTheDocument();
});
it('renders GenAI buttons with non-empty expression', async () => {
const { findByText } = render(<SqlQueryActions {...defaultProps} />);
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(<SqlQueryActions {...customProps} />);
expect(await findByText('Improve query')).toBeInTheDocument();
});
it('renders View explanation button when shouldShowViewExplanation is true', async () => {
mockContextValue.shouldShowViewExplanation = true;
const { findByText } = render(<SqlQueryActions {...defaultProps} />);
expect(await findByText('View explanation')).toBeInTheDocument();
});
it('renders Explain query button when shouldShowViewExplanation is false', async () => {
mockContextValue.shouldShowViewExplanation = false;
const { findByText } = render(<SqlQueryActions {...defaultProps} />);
expect(await findByText('Explain query')).toBeInTheDocument();
});
it('renders SuggestionsDrawerButton when there are suggestions', async () => {
mockContextValue.suggestions = ['suggestion1', 'suggestion2'];
const { findByTestId } = render(<SqlQueryActions {...defaultProps} />);
expect(await findByTestId('suggestions-badge')).toBeInTheDocument();
});
it('does not render SuggestionsDrawerButton when there are no suggestions', async () => {
mockContextValue.suggestions = [];
const { queryByTestId } = render(<SqlQueryActions {...defaultProps} />);
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(<SqlQueryActions {...defaultProps} />);
const button = await findByText('View explanation');
fireEvent.click(button);
expect(mockHandleOpen).toHaveBeenCalled();
});
});
@@ -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<string, unknown>;
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 (
<Stack direction="row" gap={1} alignItems="center" justifyContent="start" wrap>
<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={currentQuery}
onExplain={handleExplain}
queryContext={queryContext}
refIds={refIds}
// schemas={schemas} // Will be added when schema extraction is implemented
/>
)}
</Suspense>
<Suspense fallback={null}>
<GenAISQLSuggestionsButton
currentQuery={currentQuery}
initialQuery={initialQuery}
onGenerate={() => {}} // 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
/>
</Suspense>
{suggestions.length > 0 && (
<Suspense fallback={null}>
<SuggestionsDrawerButton handleOpenDrawer={handleOpenDrawer} suggestions={suggestions} />
</Suspense>
)}
</Stack>
);
};
+2 -7
View File
@@ -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"