Clean up Schema Inspector feature code (#115514)
Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com>
This commit is contained in:
co-authored by
Alex Spencer
parent
8cfac85b48
commit
6daa7ff729
+25
-55
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user