blob: 912e91d25fab9197e078e118b71d839634b65f4d [file] [log] [blame]
/**
* 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<any, SQLEditorWithValidationProps>(
(
{
// 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 (
<Flex vertical gap="middle">
<SQLEditor
ref={ref}
value={value}
onChange={handleChange}
{...sqlEditorProps}
/>
{showValidation && (
<Flex align="center" gap="small" style={{ minHeight: 32 }}>
<Tooltip title={t('Validate your expression')}>
<Button
buttonSize="small"
buttonStyle={validationResult ? 'secondary' : 'primary'}
loading={isValidating}
onClick={handleValidate}
disabled={!value || !datasourceId || isValidating}
icon={<Icons.CaretRightFilled />}
aria-label={t('Validate your expression')}
/>
</Tooltip>
<StyledValidationMessage
isError={validationResult ? !validationResult.isValid : false}
isUnverified={!validationResult && !isValidating}
isValidating={isValidating}
>
{isValidating ? (
<span>{t('Validating...')}</span>
) : validationResult ? (
<>
{validationResult.isValid ? (
<>
<Icons.CheckCircleOutlined />
<span>{t('Valid SQL expression')}</span>
</>
) : (
<>
<Icons.WarningOutlined />
<Tooltip
title={
validationResult.errors
?.map(e => e.message)
.join('\n') || t('Invalid expression')
}
placement="top"
>
<span>
{validationResult.errors &&
validationResult.errors.length > 0
? validationResult.errors[0].message
: t('Invalid expression')}
</span>
</Tooltip>
</>
)}
</>
) : (
<>
<Icons.WarningOutlined />
<span>{t('Unverified')}</span>
</>
)}
</StyledValidationMessage>
</Flex>
)}
</Flex>
);
},
);
SQLEditorWithValidation.displayName = 'SQLEditorWithValidation';
export default SQLEditorWithValidation;