feat: Visualize SqlLab.Query model data in Explore 📈 (#20281)

This commit is contained in:
Hugh A. Miles II
2022-07-15 19:34:02 -04:00
committed by GitHub
parent c70d102b73
commit e5e8867394
61 changed files with 2510 additions and 610 deletions

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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>