diff --git a/superset-frontend/src/components/SQLEditorWithValidation/SQLEditorWithValidation.test.tsx b/superset-frontend/src/components/SQLEditorWithValidation/SQLEditorWithValidation.test.tsx new file mode 100644 index 00000000000..4be56bb5ca3 --- /dev/null +++ b/superset-frontend/src/components/SQLEditorWithValidation/SQLEditorWithValidation.test.tsx @@ -0,0 +1,430 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import { SupersetClient } from '@superset-ui/core'; +import { SqlExpressionType } from '../../types/SqlExpression'; +import SQLEditorWithValidation from './index'; + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + SupersetClient: { + post: jest.fn(), + }, +})); + +const defaultProps = { + value: 'SELECT * FROM users', + onChange: jest.fn(), + showValidation: true, + datasourceId: 1, + datasourceType: 'table', +}; + +describe('SQLEditorWithValidation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders SQLEditor with validation bar when showValidation is true', () => { + render(); + + expect(screen.getByText('Unverified')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Validate your expression' }), + ).toBeInTheDocument(); + }); + + it('does not render validation bar when showValidation is false', () => { + render( + , + ); + + expect(screen.queryByText('Unverified')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Validate your expression' }), + ).not.toBeInTheDocument(); + }); + + it('shows primary button style when unverified', () => { + render(); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + expect(validateButton).toBeInTheDocument(); + // Button should have primary styling (this would need to check actual class or style) + }); + + it('disables validate button when no value or datasourceId', () => { + render( + , + ); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + expect(validateButton).toBeDisabled(); + }); + + it('shows validating state when validation is in progress', async () => { + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + + // Mock a slow API response + mockPost.mockImplementation( + () => + new Promise(resolve => + setTimeout(() => resolve({ json: { result: [] } } as any), 100), + ), + ); + + render(); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect(screen.getByText('Validating...')).toBeInTheDocument(); + expect(validateButton).toBeDisabled(); + }); + }); + + it('shows success state when validation passes', async () => { + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + mockPost.mockResolvedValue({ json: { result: [] } } as any); + + render(); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect(screen.getByText('Valid SQL expression')).toBeInTheDocument(); + }); + + // Button should become secondary style after validation + expect(validateButton).toBeInTheDocument(); + }); + + it('shows error state when validation fails', async () => { + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + mockPost.mockResolvedValue({ + json: { + result: [ + { + message: "Column 'invalid_col' does not exist", + line_number: 1, + start_column: 7, + end_column: 17, + }, + ], + }, + } as any); + + render(); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect( + screen.getByText(/Column 'invalid_col' does not exist/), + ).toBeInTheDocument(); + }); + }); + + it('handles API errors gracefully', async () => { + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + mockPost.mockRejectedValue(new Error('Network error')); + + render(); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect( + screen.getByText('Failed to validate expression. Please try again.'), + ).toBeInTheDocument(); + }); + }); + + it('sends correct payload for column expression', async () => { + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + mockPost.mockResolvedValue({ json: { result: [] } } as any); + + render( + , + ); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith({ + endpoint: '/api/v1/datasource/table/1/validate_expression/', + body: JSON.stringify({ + expression: 'user_id * 2', + expression_type: SqlExpressionType.COLUMN, + clause: undefined, + }), + headers: { 'Content-Type': 'application/json' }, + }); + }); + }); + + it('sends correct payload for WHERE expression', async () => { + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + mockPost.mockResolvedValue({ json: { result: [] } } as any); + + render( + , + ); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith({ + endpoint: '/api/v1/datasource/table/1/validate_expression/', + body: JSON.stringify({ + expression: "status = 'active'", + expression_type: SqlExpressionType.WHERE, + }), + headers: { 'Content-Type': 'application/json' }, + }); + }); + }); + + it('sends correct payload for HAVING expression', async () => { + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + mockPost.mockResolvedValue({ json: { result: [] } } as any); + + render( + , + ); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith({ + endpoint: '/api/v1/datasource/table/1/validate_expression/', + body: JSON.stringify({ + expression: 'COUNT(*) > 5', + expression_type: SqlExpressionType.HAVING, + }), + headers: { 'Content-Type': 'application/json' }, + }); + }); + }); + + it('resets validation state when value changes', () => { + const { rerender } = render(); + + // Simulate having a validation result + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + // Change the value + rerender( + , + ); + + // Should reset to unverified state + expect(screen.getByText('Unverified')).toBeInTheDocument(); + }); + + it('calls onChange when editor value changes', () => { + const onChange = jest.fn(); + render(); + + // This would require mocking the SQLEditor component to properly test onChange + // For now, we can test that the prop is passed through correctly + expect(onChange).toBeDefined(); + }); + + it('calls onValidationComplete callback when provided', async () => { + const onValidationComplete = jest.fn(); + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + mockPost.mockResolvedValue({ json: { result: [] } } as any); + + render( + , + ); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect(onValidationComplete).toHaveBeenCalledWith(true); + }); + }); + + it('calls onValidationComplete with errors when validation fails', async () => { + const onValidationComplete = jest.fn(); + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + const validationError = { + message: "Column 'invalid_col' does not exist", + line_number: 1, + start_column: 7, + end_column: 17, + }; + mockPost.mockResolvedValue({ + json: { result: [validationError] }, + } as any); + + render( + , + ); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect(onValidationComplete).toHaveBeenCalledWith(false, [ + validationError, + ]); + }); + }); + + it('shows tooltip with full error message when error is truncated', async () => { + const longErrorMessage = + 'This is a very long error message that should be truncated in the display but shown in full in the tooltip when user hovers over it'; + + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + mockPost.mockResolvedValue({ + json: { + result: [ + { + message: longErrorMessage, + line_number: 1, + start_column: 0, + end_column: 10, + }, + ], + }, + } as any); + + render(); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect( + screen.getByText(new RegExp(longErrorMessage)), + ).toBeInTheDocument(); + }); + + // Test tooltip - check that tooltip wrapper exists (not testing hover behavior) + const errorElement = screen.getByText(new RegExp(longErrorMessage)); + // The tooltip component wraps the content, but may not always add title attribute + expect(errorElement.parentElement).toBeTruthy(); + }); + + it('handles empty response gracefully', async () => { + const mockPost = SupersetClient.post as jest.MockedFunction< + typeof SupersetClient.post + >; + mockPost.mockResolvedValue({ json: { result: null } } as any); + + render(); + + const validateButton = screen.getByRole('button', { + name: 'Validate your expression', + }); + fireEvent.click(validateButton); + + await waitFor(() => { + expect(screen.getByText('Valid SQL expression')).toBeInTheDocument(); + }); + }); +}); diff --git a/superset-frontend/src/components/SQLEditorWithValidation/index.tsx b/superset-frontend/src/components/SQLEditorWithValidation/index.tsx new file mode 100644 index 00000000000..912e91d25fa --- /dev/null +++ b/superset-frontend/src/components/SQLEditorWithValidation/index.tsx @@ -0,0 +1,259 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useCallback, useState, useEffect, forwardRef } from 'react'; +import { styled, t, SupersetClient } from '@superset-ui/core'; +import { + SQLEditor, + Button, + Icons, + Tooltip, + Flex, +} from '@superset-ui/core/components'; +import { + ExpressionType, + ValidationError, + ValidationResponse, +} from '../../types/SqlExpression'; + +interface SQLEditorWithValidationProps { + // SQLEditor props - we'll accept any props that SQLEditor accepts + value: string; + onChange: (value: string) => void; + // Validation-specific props + showValidation?: boolean; + expressionType?: ExpressionType; + datasourceId?: number; + datasourceType?: string; + clause?: string; // For filters: "WHERE" or "HAVING" + onValidationComplete?: (isValid: boolean, errors?: ValidationError[]) => void; + // Any other props will be passed through to SQLEditor + [key: string]: any; +} + +const StyledValidationMessage = styled.div<{ + isError?: boolean; + isUnverified?: boolean; + isValidating?: boolean; +}>` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit}px; + color: ${({ theme, isError, isUnverified, isValidating }) => { + if (isUnverified || isValidating) return theme.colorTextTertiary; + return isError ? theme.colorErrorText : theme.colorSuccessText; + }}; + font-size: ${({ theme }) => theme.fontSizeSM}px; + flex: 1; + min-width: 0; + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +`; + +const SQLEditorWithValidation = forwardRef( + ( + { + // Required props + value, + onChange, + // Validation props + showValidation = false, + expressionType = 'column', + datasourceId, + datasourceType, + clause, + onValidationComplete, + // All other props will be passed through to SQLEditor + ...sqlEditorProps + }, + ref, + ) => { + const [isValidating, setIsValidating] = useState(false); + const [validationResult, setValidationResult] = useState<{ + isValid: boolean; + errors?: ValidationError[]; + } | null>(null); + + // Reset validation state when value prop changes + useEffect(() => { + if (validationResult !== null || isValidating) { + setValidationResult(null); + setIsValidating(false); + } + }, [value]); + + const handleValidate = useCallback(async () => { + if (!value || !datasourceId || !datasourceType) { + const error = { + message: !value + ? t('Expression cannot be empty') + : t('Datasource is required for validation'), + }; + setValidationResult({ + isValid: false, + errors: [error], + }); + onValidationComplete?.(false, [error]); + return; + } + + setIsValidating(true); + setValidationResult(null); + + try { + const endpoint = `/api/v1/datasource/${datasourceType}/${datasourceId}/validate_expression/`; + const payload = { + expression: value, + expression_type: expressionType, + clause, + }; + + const response = await SupersetClient.post({ + endpoint, + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + }); + + const data = response.json as ValidationResponse; + + if (data.result && data.result.length > 0) { + // Has validation errors + setValidationResult({ + isValid: false, + errors: data.result, + }); + onValidationComplete?.(false, data.result); + } else { + // No errors, validation successful + setValidationResult({ + isValid: true, + }); + onValidationComplete?.(true); + } + } catch (error) { + console.error('Error validating expression:', error); + const validationError = { + message: t('Failed to validate expression. Please try again.'), + }; + setValidationResult({ + isValid: false, + errors: [validationError], + }); + onValidationComplete?.(false, [validationError]); + } finally { + setIsValidating(false); + } + }, [ + value, + expressionType, + datasourceId, + datasourceType, + clause, + onValidationComplete, + ]); + + // Reset validation when value changes + const handleChange = useCallback( + (newValue: string) => { + onChange(newValue); + // Clear validation result when expression changes + if (validationResult !== null) { + setValidationResult(null); + } + }, + [onChange, validationResult], + ); + + return ( + + + + {showValidation && ( + + +