diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index 85ba2ee0fce..cfa63a541db 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -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 = ({ ); const [showCreateAsModal, setShowCreateAsModal] = useState(false); const [createAs, setCreateAs] = useState(''); + const currentSQL = useRef(queryEditor.sql); const showEmptyState = useMemo( () => !database || isEmpty(database), [database], @@ -648,6 +650,7 @@ const SqlEditor: FC = ({ ); const onSqlChanged = useEffectEvent((sql: string) => { + currentSQL.current = sql; dispatch(queryEditorSetSql(queryEditor, sql)); }); @@ -890,6 +893,73 @@ const SqlEditor: FC = ({ dispatch(queryEditorSetCursorPosition(queryEditor, newPosition)); }; + const copyQuery = (callback: (text: string) => void) => { + callback(currentSQL.current); + }; + const renderCopyQueryButton = () => ( + + ); + + const renderDatasetWarning = () => ( + + } + description={ +
+
+

+ {' '} + {t(`You are edting a query from the virtual dataset `) + + queryEditor.name} +

+

+ {t( + 'After making the changes, copy the query and paste in the virtual dataset SQL snippet settings.', + )}{' '} +

+
+
+ } + message="" + /> + ); + const queryPane = () => { const { aceEditorHeight, southPaneHeight } = getAceEditorAndSouthPaneHeights(height, northPercent, southPercent); @@ -899,7 +969,7 @@ const SqlEditor: FC = ({ 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 = ({ startQuery={startQuery} /> )} + {queryEditor.isDataset && renderDatasetWarning()} {isActive && ( { schema, autorun, sql, + isDataset: this.context.isDataset, }; this.props.actions.addQueryEditor(newQueryEditor); } diff --git a/superset-frontend/src/SqlLab/types.ts b/superset-frontend/src/SqlLab/types.ts index 3cff1d7eb28..f4386b99de7 100644 --- a/superset-frontend/src/SqlLab/types.ts +++ b/superset-frontend/src/SqlLab/types.ts @@ -67,6 +67,7 @@ export interface QueryEditor { southPercent?: number; updatedAt?: number; cursorPosition?: CursorPosition; + isDataset?: boolean; } export type toastState = { diff --git a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx index 4f21e7ae609..9723e825875 100644 --- a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx +++ b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx @@ -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) => ( ); @@ -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 = () => ( +
css` + position: absolute; + background: ${theme.colors.secondary.light5}; + align-items: center; + display: flex; + height: 100%; + width: 100%; + justify-content: center; + `} + > +
+ + css` + display: block; + margin: ${theme.gridUnit * 4}px auto; + width: fit-content; + color: ${theme.colors.grayscale.base}; + `} + > + {t('We are working on your query')} + +
+
+ ); + + renderOpenInSqlLabLink(isError = false) { + return ( + 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')} + + ); + } + + renderSqlErrorMessage = () => ( + <> + css` + font-size: ${theme.typography.sizes.s}px; + `} + > + {this.props.database?.error && t('Error executing query. ')} + + {this.renderOpenInSqlLabLink(true)} + css` + font-size: ${theme.typography.sizes.s}px; + `} + > + {t(' to check for details.')} + + + ); + renderSourceFieldset() { const { datasource } = this.state; + const floatingButtonCss = css` + align-self: flex-end; + height: 24px; + padding-left: 6px; + padding-right: 6px; + `; return (
@@ -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={ - + this.props.database?.isLoading ? ( + <> + {this.renderSqlEditorOverlay()} + + + ) : ( + + ) } additionalControl={
+
} errorMessage={ - this.props.database?.error && t('Error executing query.') + this.props.database?.error && this.renderSqlErrorMessage() } /> {this.props.database?.queryResult && ( - col.column_name, - )} - height={100} - expandedColumns={ - this.props.database.queryResult.expandedColumns - } - allowHTML - /> + <> +
css` + margin-bottom: ${theme.gridUnit * 4}px; + `} + > + css` + color: ${theme.colors.grayscale.base}; + font-size: ${theme.typography.sizes.s}px; + `} + > + {t( + 'In this view you can preview the first 25 rows. ', + )} + + {this.renderOpenInSqlLabLink()} + css` + color: ${theme.colors.grayscale.base}; + font-size: ${theme.typography.sizes.s}px; + `} + > + {t(' to see details.')} + +
+ 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), diff --git a/superset-frontend/src/components/Datasource/DatasourceEditor.test.jsx b/superset-frontend/src/components/Datasource/DatasourceEditor.test.jsx index c748fd86cda..f8a1c4d3db8 100644 --- a/superset-frontend/src/components/Datasource/DatasourceEditor.test.jsx +++ b/superset-frontend/src/components/Datasource/DatasourceEditor.test.jsx @@ -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(, { + render(, { useRedux: true, initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } }, + useRouter: true, }), ); diff --git a/superset-frontend/src/components/Datasource/DatasourceModal.test.jsx b/superset-frontend/src/components/Datasource/DatasourceModal.test.jsx index af20f8f4ae2..d84411f48ef 100644 --- a/superset-frontend/src/components/Datasource/DatasourceModal.test.jsx +++ b/superset-frontend/src/components/Datasource/DatasourceModal.test.jsx @@ -49,11 +49,15 @@ const mockedProps = { }; let container; - +const routeProps = { + history: {}, + location: {}, + match: {}, +}; async function renderAndWait(props = mockedProps) { const { container: renderedContainer } = render( - , - { store }, + , + { store, useRouter: true }, ); container = renderedContainer; diff --git a/superset-frontend/src/components/Datasource/Field.tsx b/superset-frontend/src/components/Datasource/Field.tsx index 61404d401b4..289f1eac472 100644 --- a/superset-frontend/src/components/Datasource/Field.tsx +++ b/superset-frontend/src/components/Datasource/Field.tsx @@ -39,7 +39,7 @@ interface FieldProps { onChange: (fieldKey: string, newValue: V) => void; compact: boolean; inline: boolean; - errorMessage?: string; + errorMessage?: string | ReactElement; } export default function Field({ diff --git a/superset-frontend/src/components/Datasource/utils.js b/superset-frontend/src/components/Datasource/utils.js index 2c3f080a3f2..dd319adbe2e 100644 --- a/superset-frontend/src/components/Datasource/utils.js +++ b/superset-frontend/src/components/Datasource/utils.js @@ -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, diff --git a/superset-frontend/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/src/components/Icons/AntdEnhanced.tsx index 65f2b4bd3dd..43eb75b426a 100644 --- a/superset-frontend/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/src/components/Icons/AntdEnhanced.tsx @@ -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; diff --git a/superset-frontend/src/pages/SqlLab/LocationContext.tsx b/superset-frontend/src/pages/SqlLab/LocationContext.tsx index d42b899b4d2..c972f0cbcd0 100644 --- a/superset-frontend/src/pages/SqlLab/LocationContext.tsx +++ b/superset-frontend/src/pages/SqlLab/LocationContext.tsx @@ -23,6 +23,7 @@ import { useLocation } from 'react-router-dom'; export type LocationState = { requestedQuery?: Record; + isDataset?: boolean; }; export const locationContext = createContext({}); @@ -32,7 +33,24 @@ const EMPTY_STATE: LocationState = {}; export const LocationProvider: FC = ({ children }: { children: ReactNode }) => { const location = useLocation(); - return {children}; -}; + if (location.state) { + return {children}; + } + 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 {children}; + } + + return {children}; +}; export const useLocationState = () => useContext(locationContext);