mirror of
https://github.com/apache/superset.git
synced 2026-05-12 19:35:17 +00:00
feat: Visualize SqlLab.Query model data in Explore 📈 (#20281)
This commit is contained in:
@@ -20,13 +20,19 @@ import React from 'react';
|
||||
import sinon from 'sinon';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
|
||||
import {
|
||||
supersetTheme,
|
||||
ThemeProvider,
|
||||
DatasourceType,
|
||||
} from '@superset-ui/core';
|
||||
import { Menu } from 'src/components/Menu';
|
||||
import {
|
||||
DatasourceModal,
|
||||
ChangeDatasourceModal,
|
||||
} from 'src/components/Datasource';
|
||||
import DatasourceControl from 'src/explore/components/controls/DatasourceControl';
|
||||
import DatasourceControl, {
|
||||
getDatasourceTitle,
|
||||
} from 'src/explore/components/controls/DatasourceControl';
|
||||
import Icons from 'src/components/Icons';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
|
||||
@@ -110,29 +116,6 @@ describe('DatasourceControl', () => {
|
||||
</div>,
|
||||
);
|
||||
expect(menuWrapper.find(Menu.Item)).toHaveLength(2);
|
||||
|
||||
wrapper = setup({
|
||||
datasource: {
|
||||
name: 'birth_names',
|
||||
type: 'druid',
|
||||
uid: '1__druid',
|
||||
id: 1,
|
||||
columns: [],
|
||||
metrics: [],
|
||||
owners: [{ username: 'admin', userId: 1 }],
|
||||
database: {
|
||||
backend: 'druid',
|
||||
name: 'main',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('[data-test="datasource-menu"]')).toExist();
|
||||
menuWrapper = shallow(
|
||||
<div>
|
||||
{wrapper.find('[data-test="datasource-menu"]').first().prop('overlay')}
|
||||
</div>,
|
||||
);
|
||||
expect(menuWrapper.find(Menu.Item)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render health check message', () => {
|
||||
@@ -143,4 +126,30 @@ describe('DatasourceControl', () => {
|
||||
defaultProps.datasource.health_check_message,
|
||||
);
|
||||
});
|
||||
|
||||
it('Gets Datasource Title', () => {
|
||||
const sql = 'This is the sql';
|
||||
const name = 'this is a name';
|
||||
const emptyResult = '';
|
||||
const queryDatasource1 = { type: DatasourceType.Query, sql };
|
||||
let displayText = getDatasourceTitle(queryDatasource1);
|
||||
expect(displayText).toBe(sql);
|
||||
const queryDatasource2 = { type: DatasourceType.Query, sql: null };
|
||||
displayText = getDatasourceTitle(queryDatasource2);
|
||||
expect(displayText).toBe(null);
|
||||
const queryDatasource3 = { type: 'random type', name };
|
||||
displayText = getDatasourceTitle(queryDatasource3);
|
||||
expect(displayText).toBe(name);
|
||||
const queryDatasource4 = { type: 'random type' };
|
||||
displayText = getDatasourceTitle(queryDatasource4);
|
||||
expect(displayText).toBe(emptyResult);
|
||||
displayText = getDatasourceTitle();
|
||||
expect(displayText).toBe(emptyResult);
|
||||
displayText = getDatasourceTitle(null);
|
||||
expect(displayText).toBe(emptyResult);
|
||||
displayText = getDatasourceTitle('I should not be a string');
|
||||
expect(displayText).toBe(emptyResult);
|
||||
displayText = getDatasourceTitle([]);
|
||||
expect(displayText).toBe(emptyResult);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ test('Click on Save as dataset', () => {
|
||||
|
||||
// Renders a save dataset modal
|
||||
const saveRadioBtn = screen.getByRole('radio', {
|
||||
name: /save as new undefined/i,
|
||||
name: /save as new/i,
|
||||
});
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
name: /overwrite existing/i,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
@@ -26,8 +27,8 @@ import {
|
||||
t,
|
||||
withTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { AntdDropdown } from 'src/components';
|
||||
import { Menu } from 'src/components/Menu';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
@@ -40,9 +41,14 @@ import Button from 'src/components/Button';
|
||||
import ErrorAlert from 'src/components/ErrorMessage/ErrorAlert';
|
||||
import WarningIconWithTooltip from 'src/components/WarningIconWithTooltip';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
import ModalTrigger from 'src/components/ModalTrigger';
|
||||
import ViewQueryModalFooter from 'src/explore/components/controls/ViewQueryModalFooter';
|
||||
import ViewQuery from 'src/explore/components/controls/ViewQuery';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
@@ -104,7 +110,7 @@ const Styles = styled.div`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dataset-svg {
|
||||
.datasource-svg {
|
||||
margin-right: ${({ theme }) => 2 * theme.gridUnit}px;
|
||||
flex: none;
|
||||
}
|
||||
@@ -122,24 +128,49 @@ const EDIT_DATASET = 'edit_dataset';
|
||||
const QUERY_PREVIEW = 'query_preview';
|
||||
const SAVE_AS_DATASET = 'save_as_dataset';
|
||||
|
||||
// If the string is longer than this value's number characters we add
|
||||
// a tooltip for user can see the full name by hovering over the visually truncated string in UI
|
||||
const VISIBLE_TITLE_LENGTH = 25;
|
||||
|
||||
// Assign icon for each DatasourceType. If no icon assingment is found in the lookup, no icon will render
|
||||
export const datasourceIconLookup = {
|
||||
[DatasourceType.Query]: (
|
||||
<Icons.ConsoleSqlOutlined className="datasource-svg" />
|
||||
),
|
||||
[DatasourceType.Table]: <Icons.DatasetPhysical className="datasource-svg" />,
|
||||
};
|
||||
|
||||
// Render title for datasource with tooltip only if text is longer than VISIBLE_TITLE_LENGTH
|
||||
export const renderDatasourceTitle = (displayString, tooltip) =>
|
||||
displayString?.length > VISIBLE_TITLE_LENGTH ? (
|
||||
// Add a tooltip only for long names that will be visually truncated
|
||||
<Tooltip title={tooltip}>
|
||||
<span className="title-select">{displayString}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span title={tooltip} className="title-select">
|
||||
{displayString}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Different data source types use different attributes for the display title
|
||||
export const getDatasourceTitle = datasource => {
|
||||
if (datasource?.type === 'query') return datasource?.sql;
|
||||
return datasource?.name || '';
|
||||
};
|
||||
|
||||
class DatasourceControl extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showEditDatasourceModal: false,
|
||||
showChangeDatasourceModal: false,
|
||||
showSaveDatasetModal: false,
|
||||
};
|
||||
this.onDatasourceSave = this.onDatasourceSave.bind(this);
|
||||
this.toggleChangeDatasourceModal =
|
||||
this.toggleChangeDatasourceModal.bind(this);
|
||||
this.toggleEditDatasourceModal = this.toggleEditDatasourceModal.bind(this);
|
||||
this.toggleShowDatasource = this.toggleShowDatasource.bind(this);
|
||||
this.handleMenuItemClick = this.handleMenuItemClick.bind(this);
|
||||
this.toggleSaveDatasetModal = this.toggleSaveDatasetModal.bind(this);
|
||||
}
|
||||
|
||||
onDatasourceSave(datasource) {
|
||||
this.props.actions.changeDatasource(datasource);
|
||||
onDatasourceSave = datasource => {
|
||||
this.props.actions.setDatasource(datasource);
|
||||
const timeCol = this.props.form_data?.granularity_sqla;
|
||||
const { columns } = this.props.datasource;
|
||||
const firstDttmCol = columns.find(column => column.is_dttm);
|
||||
@@ -156,33 +187,33 @@ class DatasourceControl extends React.PureComponent {
|
||||
if (this.props.onDatasourceSave) {
|
||||
this.props.onDatasourceSave(datasource);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
toggleShowDatasource() {
|
||||
toggleShowDatasource = () => {
|
||||
this.setState(({ showDatasource }) => ({
|
||||
showDatasource: !showDatasource,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
toggleChangeDatasourceModal() {
|
||||
toggleChangeDatasourceModal = () => {
|
||||
this.setState(({ showChangeDatasourceModal }) => ({
|
||||
showChangeDatasourceModal: !showChangeDatasourceModal,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
toggleEditDatasourceModal() {
|
||||
toggleEditDatasourceModal = () => {
|
||||
this.setState(({ showEditDatasourceModal }) => ({
|
||||
showEditDatasourceModal: !showEditDatasourceModal,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
toggleSaveDatasetModal() {
|
||||
toggleSaveDatasetModal = () => {
|
||||
this.setState(({ showSaveDatasetModal }) => ({
|
||||
showSaveDatasetModal: !showSaveDatasetModal,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleMenuItemClick({ key }) {
|
||||
handleMenuItemClick = ({ key }) => {
|
||||
switch (key) {
|
||||
case CHANGE_DATASET:
|
||||
this.toggleChangeDatasourceModal();
|
||||
@@ -205,9 +236,6 @@ class DatasourceControl extends React.PureComponent {
|
||||
}
|
||||
break;
|
||||
|
||||
case QUERY_PREVIEW:
|
||||
break;
|
||||
|
||||
case SAVE_AS_DATASET:
|
||||
this.toggleSaveDatasetModal();
|
||||
break;
|
||||
@@ -215,7 +243,7 @@ class DatasourceControl extends React.PureComponent {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
@@ -224,7 +252,7 @@ class DatasourceControl extends React.PureComponent {
|
||||
showSaveDatasetModal,
|
||||
} = this.state;
|
||||
const { datasource, onChange, theme } = this.props;
|
||||
const isMissingDatasource = datasource.id == null;
|
||||
const isMissingDatasource = datasource?.id == null;
|
||||
let isMissingParams = false;
|
||||
if (isMissingDatasource) {
|
||||
const datasourceId = getUrlParam(URL_PARAMS.datasourceId);
|
||||
@@ -234,7 +262,6 @@ class DatasourceControl extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
const isSqlSupported = datasource.type === 'table';
|
||||
const { user } = this.props;
|
||||
const allowEdit =
|
||||
datasource.owners?.map(o => o.id || o.value).includes(user.userId) ||
|
||||
@@ -264,7 +291,7 @@ class DatasourceControl extends React.PureComponent {
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key={CHANGE_DATASET}>{t('Change dataset')}</Menu.Item>
|
||||
{isSqlSupported && (
|
||||
{datasource && (
|
||||
<Menu.Item key={VIEW_IN_SQL_LAB}>{t('View in SQL Lab')}</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
@@ -272,7 +299,28 @@ class DatasourceControl extends React.PureComponent {
|
||||
|
||||
const queryDatasourceMenu = (
|
||||
<Menu onClick={this.handleMenuItemClick}>
|
||||
<Menu.Item key={QUERY_PREVIEW}>{t('Query preview')}</Menu.Item>
|
||||
<Menu.Item key={QUERY_PREVIEW}>
|
||||
<ModalTrigger
|
||||
triggerNode={
|
||||
<span data-test="view-query-menu-item">{t('Query preview')}</span>
|
||||
}
|
||||
modalTitle={t('Query preview')}
|
||||
modalBody={
|
||||
<ViewQuery
|
||||
sql={datasource?.sql || datasource?.select_star || ''}
|
||||
/>
|
||||
}
|
||||
modalFooter={
|
||||
<ViewQueryModalFooter
|
||||
changeDatasource={this.toggleSaveDatasetModal}
|
||||
datasource={datasource}
|
||||
/>
|
||||
}
|
||||
draggable={false}
|
||||
resizable={false}
|
||||
responsive
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item key={VIEW_IN_SQL_LAB}>{t('View in SQL Lab')}</Menu.Item>
|
||||
<Menu.Item key={SAVE_AS_DATASET}>{t('Save as dataset')}</Menu.Item>
|
||||
</Menu>
|
||||
@@ -280,27 +328,25 @@ class DatasourceControl extends React.PureComponent {
|
||||
|
||||
const { health_check_message: healthCheckMessage } = datasource;
|
||||
|
||||
let extra = {};
|
||||
let extra;
|
||||
if (datasource?.extra) {
|
||||
try {
|
||||
extra = JSON.parse(datasource?.extra);
|
||||
} catch {} // eslint-disable-line no-empty
|
||||
if (isString(datasource.extra)) {
|
||||
try {
|
||||
extra = JSON.parse(datasource.extra);
|
||||
} catch {} // eslint-disable-line no-empty
|
||||
} else {
|
||||
extra = datasource.extra; // eslint-disable-line prefer-destructuring
|
||||
}
|
||||
}
|
||||
|
||||
const titleText = getDatasourceTitle(datasource);
|
||||
const tooltip = titleText;
|
||||
|
||||
return (
|
||||
<Styles data-test="datasource-control" className="DatasourceControl">
|
||||
<div className="data-container">
|
||||
<Icons.DatasetPhysical className="dataset-svg" />
|
||||
{/* Add a tooltip only for long dataset names */}
|
||||
{!isMissingDatasource && datasource.name.length > 25 ? (
|
||||
<Tooltip title={datasource.name}>
|
||||
<span className="title-select">{datasource.name}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span title={datasource.name} className="title-select">
|
||||
{datasource.name}
|
||||
</span>
|
||||
)}
|
||||
{datasourceIconLookup[datasource?.type]}
|
||||
{renderDatasourceTitle(titleText, tooltip)}
|
||||
{healthCheckMessage && (
|
||||
<Tooltip title={healthCheckMessage}>
|
||||
<Icons.AlertSolid iconColor={theme.colors.warning.base} />
|
||||
@@ -318,12 +364,10 @@ class DatasourceControl extends React.PureComponent {
|
||||
trigger={['click']}
|
||||
data-test="datasource-menu"
|
||||
>
|
||||
<Tooltip title={t('More dataset related options')}>
|
||||
<Icons.MoreVert
|
||||
className="datasource-modal-trigger"
|
||||
data-test="datasource-menu-trigger"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Icons.MoreVert
|
||||
className="datasource-modal-trigger"
|
||||
data-test="datasource-menu-trigger"
|
||||
/>
|
||||
</AntdDropdown>
|
||||
</div>
|
||||
{/* missing dataset */}
|
||||
@@ -398,7 +442,9 @@ class DatasourceControl extends React.PureComponent {
|
||||
modalDescription={t(
|
||||
'Save this query as a virtual dataset to continue exploring',
|
||||
)}
|
||||
datasource={datasource}
|
||||
datasource={getDatasourceAsSaveableDataset(datasource)}
|
||||
openWindow={false}
|
||||
formData={this.props.form_data}
|
||||
/>
|
||||
)}
|
||||
</Styles>
|
||||
|
||||
Reference in New Issue
Block a user