feat(Dataset): editor improvements - run in sqllab (#33443)

This commit is contained in:
Rafael Benitez
2025-06-04 14:47:12 -04:00
committed by GitHub
parent ff34e3c81e
commit a7aa8f7cef
10 changed files with 287 additions and 50 deletions

View File

@@ -115,6 +115,7 @@ import {
LOG_ACTIONS_SQLLAB_STOP_QUERY,
Logger,
} from 'src/logger/LogUtils';
import CopyToClipboard from 'src/components/CopyToClipboard';
import TemplateParamsEditor from '../TemplateParamsEditor';
import SouthPane from '../SouthPane';
import SaveQuery, { QueryPayload } from '../SaveQuery';
@@ -315,6 +316,7 @@ const SqlEditor: FC<Props> = ({
);
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
const [createAs, setCreateAs] = useState('');
const currentSQL = useRef<string>(queryEditor.sql);
const showEmptyState = useMemo(
() => !database || isEmpty(database),
[database],
@@ -648,6 +650,7 @@ const SqlEditor: FC<Props> = ({
);
const onSqlChanged = useEffectEvent((sql: string) => {
currentSQL.current = sql;
dispatch(queryEditorSetSql(queryEditor, sql));
});
@@ -890,6 +893,73 @@ const SqlEditor: FC<Props> = ({
dispatch(queryEditorSetCursorPosition(queryEditor, newPosition));
};
const copyQuery = (callback: (text: string) => void) => {
callback(currentSQL.current);
};
const renderCopyQueryButton = () => (
<Button type="primary">{t('COPY QUERY')}</Button>
);
const renderDatasetWarning = () => (
<Alert
css={css`
margin-bottom: ${theme.gridUnit * 2}px;
padding-top: ${theme.gridUnit * 4}px;
.antd5-alert-action {
align-self: center;
}
`}
type="info"
action={
<CopyToClipboard
wrapText={false}
copyNode={renderCopyQueryButton()}
getText={copyQuery}
/>
}
description={
<div
css={css`
display: flex;
justify-content: space-between;
align-items: center;
`}
>
<div
css={css`
display: flex;
flex-direction: column;
`}
>
<p
css={css`
font-size: ${theme.typography.sizes.m}px;
font-weight: ${theme.typography.weights.medium};
color: ${theme.colors.primary.dark2};
`}
>
{' '}
{t(`You are edting a query from the virtual dataset `) +
queryEditor.name}
</p>
<p
css={css`
font-size: ${theme.typography.sizes.m}px;
font-weight: ${theme.typography.weights.normal};
color: ${theme.colors.primary.dark2};
`}
>
{t(
'After making the changes, copy the query and paste in the virtual dataset SQL snippet settings.',
)}{' '}
</p>
</div>
</div>
}
message=""
/>
);
const queryPane = () => {
const { aceEditorHeight, southPaneHeight } =
getAceEditorAndSouthPaneHeights(height, northPercent, southPercent);
@@ -899,7 +969,7 @@ const SqlEditor: FC<Props> = ({
className="queryPane"
sizes={[northPercent, southPercent]}
elementStyle={elementStyle}
minSize={200}
minSize={queryEditor.isDataset ? 400 : 200}
direction="vertical"
gutterSize={SQL_EDITOR_GUTTER_HEIGHT}
onDragStart={onResizeStart}
@@ -915,6 +985,7 @@ const SqlEditor: FC<Props> = ({
startQuery={startQuery}
/>
)}
{queryEditor.isDataset && renderDatasetWarning()}
{isActive && (
<AceEditorWrapper
autocomplete={autocompleteEnabled && !isTempId(queryEditor.id)}

View File

@@ -141,6 +141,7 @@ class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
schema,
autorun,
sql,
isDataset: this.context.isDataset,
};
this.props.actions.addQueryEditor(newQueryEditor);
}

View File

@@ -67,6 +67,7 @@ export interface QueryEditor {
southPercent?: number;
updatedAt?: number;
cursorPosition?: CursorPosition;
isDataset?: boolean;
}
export type toastState = {

View File

@@ -149,14 +149,6 @@ const StyledButtonWrapper = styled.span`
`}
`;
const sqlTooltipOptions = {
placement: 'topRight',
title: t(
'If changes are made to your SQL query, ' +
'columns in your dataset will be synced when saving the dataset.',
),
};
const checkboxGenerator = (d, onChange) => (
<CheckboxControl value={d} onChange={onChange} />
);
@@ -723,6 +715,22 @@ class DatasourceEditor extends PureComponent {
});
}
getSQLLabUrl() {
const queryParams = new URLSearchParams({
dbid: this.state.datasource.database.id,
sql: this.state.datasource.sql,
name: this.state.datasource.datasource_name,
schema: this.state.datasource.schema,
autorun: true,
isDataset: true,
});
return `/sqllab/?${queryParams.toString()}`;
}
openOnSqlLab() {
window.open(this.getSQLLabUrl(), '_blank', 'noopener,noreferrer');
}
tableChangeAndSyncMetadata() {
this.validate(() => {
this.syncMetadata();
@@ -996,8 +1004,81 @@ class DatasourceEditor extends PureComponent {
);
}
renderSqlEditorOverlay = () => (
<div
css={theme => css`
position: absolute;
background: ${theme.colors.secondary.light5};
align-items: center;
display: flex;
height: 100%;
width: 100%;
justify-content: center;
`}
>
<div>
<Loading position="inline-centered" />
<span
css={theme => css`
display: block;
margin: ${theme.gridUnit * 4}px auto;
width: fit-content;
color: ${theme.colors.grayscale.base};
`}
>
{t('We are working on your query')}
</span>
</div>
</div>
);
renderOpenInSqlLabLink(isError = false) {
return (
<a
href={this.getSQLLabUrl()}
target="_blank"
rel="noopener noreferrer"
css={theme => css`
color: ${isError
? theme.colors.error.base
: theme.colors.grayscale.base};
font-size: ${theme.typography.sizes.s}px;
text-decoration: underline;
`}
>
{t('Open in SQL lab')}
</a>
);
}
renderSqlErrorMessage = () => (
<>
<span
css={theme => css`
font-size: ${theme.typography.sizes.s}px;
`}
>
{this.props.database?.error && t('Error executing query. ')}
</span>
{this.renderOpenInSqlLabLink(true)}
<span
css={theme => css`
font-size: ${theme.typography.sizes.s}px;
`}
>
{t(' to check for details.')}
</span>
</>
);
renderSourceFieldset() {
const { datasource } = this.state;
const floatingButtonCss = css`
align-self: flex-end;
height: 24px;
padding-left: 6px;
padding-right: 6px;
`;
return (
<div>
<EditLockContainer>
@@ -1097,18 +1178,33 @@ class DatasourceEditor extends PureComponent {
description={t(
'When specifying SQL, the datasource acts as a view. ' +
'Superset will use this statement as a subquery while grouping and filtering ' +
'on the generated parent queries.',
'on the generated parent queries.' +
'If changes are made to your SQL query, ' +
'columns in your dataset will be synced when saving the dataset.',
)}
control={
<TextAreaControl
language="sql"
offerEditInModal={false}
minLines={10}
maxLines={Infinity}
readOnly={!this.state.isEditMode}
resize="both"
tooltipOptions={sqlTooltipOptions}
/>
this.props.database?.isLoading ? (
<>
{this.renderSqlEditorOverlay()}
<TextAreaControl
language="sql"
offerEditInModal={false}
minLines={10}
maxLines={Infinity}
readOnly={!this.state.isEditMode}
resize="both"
/>
</>
) : (
<TextAreaControl
language="sql"
offerEditInModal={false}
minLines={10}
maxLines={Infinity}
readOnly={!this.state.isEditMode}
resize="both"
/>
)
}
additionalControl={
<div
@@ -1120,12 +1216,25 @@ class DatasourceEditor extends PureComponent {
`}
>
<Button
css={css`
align-self: flex-end;
height: 24px;
padding-left: 6px;
padding-right: 6px;
`}
disabled={this.props.database?.isLoading}
tooltip={t('Open SQL Lab in a new tab')}
css={floatingButtonCss}
size="small"
>
<Icons.ExportOutlined
iconSize="s"
css={theme => ({
color: theme.colors.primary.dark1,
})}
onClick={() => {
this.openOnSqlLab();
}}
/>
</Button>
<Button
disabled={this.props.database?.isLoading}
tooltip={t('Run query')}
css={floatingButtonCss}
size="small"
buttonStyle="primary"
onClick={() => {
@@ -1142,22 +1251,49 @@ class DatasourceEditor extends PureComponent {
</div>
}
errorMessage={
this.props.database?.error && t('Error executing query.')
this.props.database?.error && this.renderSqlErrorMessage()
}
/>
{this.props.database?.queryResult && (
<ResultTable
data={this.props.database.queryResult.data}
queryId={this.props.database.queryResult.query.id}
orderedColumnKeys={this.props.database.queryResult.columns.map(
col => col.column_name,
)}
height={100}
expandedColumns={
this.props.database.queryResult.expandedColumns
}
allowHTML
/>
<>
<div
css={theme => css`
margin-bottom: ${theme.gridUnit * 4}px;
`}
>
<span
css={theme => css`
color: ${theme.colors.grayscale.base};
font-size: ${theme.typography.sizes.s}px;
`}
>
{t(
'In this view you can preview the first 25 rows. ',
)}
</span>
{this.renderOpenInSqlLabLink()}
<span
css={theme => css`
color: ${theme.colors.grayscale.base};
font-size: ${theme.typography.sizes.s}px;
`}
>
{t(' to see details.')}
</span>
</div>
<ResultTable
data={this.props.database?.queryResult.data}
queryId={this.props.database?.queryResult.query.id}
orderedColumnKeys={this.props.database?.queryResult.columns.map(
col => col.column_name,
)}
height={100}
expandedColumns={
this.props.database?.queryResult.expandedColumns
}
allowHTML
/>
</>
)}
</>
)}
@@ -1555,8 +1691,7 @@ const mapDispatchToProps = dispatch => ({
resetQuery: () => dispatch(resetDatabaseState()),
});
const mapStateToProps = state => ({
test: state.queryApi,
database: state.database,
database: state?.database,
});
export default withToasts(
connect(mapStateToProps, mapDispatchToProps)(DataSourceComponent),

View File

@@ -45,12 +45,17 @@ const props = {
},
};
const DATASOURCE_ENDPOINT = 'glob:*/datasource/external_metadata_by_name/*';
const routeProps = {
history: {},
location: {},
match: {},
};
const asyncRender = props =>
waitFor(() =>
render(<DatasourceEditor {...props} />, {
render(<DatasourceEditor {...props} {...routeProps} />, {
useRedux: true,
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
useRouter: true,
}),
);

View File

@@ -49,11 +49,15 @@ const mockedProps = {
};
let container;
const routeProps = {
history: {},
location: {},
match: {},
};
async function renderAndWait(props = mockedProps) {
const { container: renderedContainer } = render(
<DatasourceModal {...props} />,
{ store },
<DatasourceModal {...props} {...routeProps} />,
{ store, useRouter: true },
);
container = renderedContainer;

View File

@@ -39,7 +39,7 @@ interface FieldProps<V> {
onChange: (fieldKey: string, newValue: V) => void;
compact: boolean;
inline: boolean;
errorMessage?: string;
errorMessage?: string | ReactElement;
}
export default function Field<V>({

View File

@@ -28,10 +28,10 @@ export function recurseReactClone(children, type, propExtender) {
*/
return Children.map(children, child => {
let newChild = child;
if (child && child.type.name === type.name) {
if (child && child.type && child.type.name === type.name) {
newChild = cloneElement(child, propExtender(child));
}
if (newChild && newChild.props.children) {
if (newChild && newChild.props && newChild.props.children) {
newChild = cloneElement(newChild, {
children: recurseReactClone(
newChild.props.children,

View File

@@ -116,6 +116,7 @@ import {
UnorderedListOutlined,
WarningOutlined,
KeyOutlined,
ExportOutlined,
} from '@ant-design/icons';
import { FC } from 'react';
import { IconType } from './types';
@@ -221,6 +222,7 @@ const AntdIcons = {
UnorderedListOutlined,
WarningOutlined,
KeyOutlined,
ExportOutlined,
} as const;
type AntdIconNames = keyof typeof AntdIcons;

View File

@@ -23,6 +23,7 @@ import { useLocation } from 'react-router-dom';
export type LocationState = {
requestedQuery?: Record<string, any>;
isDataset?: boolean;
};
export const locationContext = createContext<LocationState>({});
@@ -32,7 +33,24 @@ const EMPTY_STATE: LocationState = {};
export const LocationProvider: FC = ({ children }: { children: ReactNode }) => {
const location = useLocation<LocationState>();
return <Provider value={location.state || EMPTY_STATE}>{children}</Provider>;
};
if (location.state) {
return <Provider value={location.state}>{children}</Provider>;
}
const queryParams = new URLSearchParams(location.search);
if (queryParams.size > 0) {
const dbid = queryParams.get('dbid');
const sql = queryParams.get('sql');
const name = queryParams.get('name');
const schema = queryParams.get('schema');
const autorun = queryParams.get('autorun') === 'true';
const queryParamsState = {
requestedQuery: { dbid, sql, name, schema, autorun },
isDataset: true,
} as LocationState;
return <Provider value={queryParamsState}>{children}</Provider>;
}
return <Provider value={EMPTY_STATE}>{children}</Provider>;
};
export const useLocationState = () => useContext(locationContext);