/**
* 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 rison from 'rison';
import { PureComponent, useCallback } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Radio } from '@superset-ui/core/components/Radio';
import {
css,
isFeatureEnabled,
getCurrencySymbol,
ensureIsArray,
FeatureFlag,
styled,
SupersetClient,
themeObject,
t,
withTheme,
getClientErrorObject,
getExtensionsRegistry,
} from '@superset-ui/core';
import Tabs from '@superset-ui/core/components/Tabs';
import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
import TableSelector from 'src/components/TableSelector';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
import TextControl from 'src/explore/components/controls/TextControl';
import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
import SpatialControl from 'src/explore/components/controls/SpatialControl';
import withToasts from 'src/components/MessageToasts/withToasts';
import CurrencyControl from 'src/explore/components/controls/CurrencyControl';
import {
Alert,
AsyncSelect,
Badge,
Button,
Card,
CertifiedBadge,
Col,
Divider,
EditableTitle,
FormLabel,
Icons,
Loading,
Row,
Select,
Typography,
Label,
} from '@superset-ui/core/components';
import { FilterableTable } from 'src/components';
import {
executeQuery,
formatQuery,
resetDatabaseState,
} from 'src/database/actions';
import Mousetrap from 'mousetrap';
import { DatabaseSelector } from '../DatabaseSelector';
import CollectionTable from './CollectionTable';
import Fieldset from './Fieldset';
import Field from './Field';
import { fetchSyncedColumns, updateColumns } from './utils';
const extensionsRegistry = getExtensionsRegistry();
const DatasourceContainer = styled.div`
.change-warning {
margin: 16px 10px 0;
color: ${({ theme }) => theme.colorWarning};
}
.change-warning .bold {
font-weight: ${({ theme }) => theme.fontWeightStrong};
}
.form-group.has-feedback > .help-block {
margin-top: 8px;
}
.form-group.form-group-md {
margin-bottom: 8px;
}
`;
const FlexRowContainer = styled.div`
align-items: center;
display: flex;
svg {
margin-right: ${({ theme }) => theme.sizeUnit}px;
}
`;
const StyledTableTabs = styled(Tabs)`
overflow: visible;
.ant-tabs-content-holder {
overflow: visible;
}
`;
const StyledBadge = styled(Badge)`
.ant-badge-count {
line-height: ${({ theme }) => theme.sizeUnit * 4}px;
height: ${({ theme }) => theme.sizeUnit * 4}px;
margin-left: ${({ theme }) => theme.sizeUnit}px;
}
`;
const EditLockContainer = styled.div`
font-size: ${({ theme }) => theme.fontSizeSM}px;
display: flex;
align-items: center;
a {
padding: 0 10px;
}
`;
const ColumnButtonWrapper = styled.div`
text-align: right;
${({ theme }) => `margin-bottom: ${theme.sizeUnit * 2}px`}
`;
const StyledLabelWrapper = styled.div`
display: flex;
align-items: center;
span {
margin-right: ${({ theme }) => theme.sizeUnit}px;
}
`;
const StyledColumnsTabWrapper = styled.div`
.table > tbody > tr > td {
vertical-align: middle;
}
.ant-tag {
margin-top: ${({ theme }) => theme.sizeUnit}px;
}
`;
const StyledButtonWrapper = styled.span`
${({ theme }) => `
margin-top: ${theme.sizeUnit * 3}px;
margin-left: ${theme.sizeUnit * 3}px;
button>span>:first-of-type {
margin-right: 0;
}
`}
`;
const checkboxGenerator = (d, onChange) => (
);
const DATA_TYPES = [
{ value: 'STRING', label: t('STRING') },
{ value: 'NUMERIC', label: t('NUMERIC') },
{ value: 'DATETIME', label: t('DATETIME') },
{ value: 'BOOLEAN', label: t('BOOLEAN') },
];
const TABS_KEYS = {
SOURCE: 'SOURCE',
METRICS: 'METRICS',
COLUMNS: 'COLUMNS',
CALCULATED_COLUMNS: 'CALCULATED_COLUMNS',
SETTINGS: 'SETTINGS',
SPATIAL: 'SPATIAL',
};
const DATASOURCE_TYPES_ARR = [
{ key: 'physical', label: t('Physical (table or view)') },
{ key: 'virtual', label: t('Virtual (SQL)') },
];
const DATASOURCE_TYPES = {};
DATASOURCE_TYPES_ARR.forEach(o => {
DATASOURCE_TYPES[o.key] = o;
});
function CollectionTabTitle({ title, collection }) {
return (
{title}{' '}
);
}
CollectionTabTitle.propTypes = {
title: PropTypes.string,
collection: PropTypes.array,
};
function ColumnCollectionTable({
columns,
datasource,
onColumnsChange,
onDatasourceChange,
editableColumnName,
showExpression,
allowAddItem,
allowEditDataType,
itemGenerator,
columnLabelTooltips,
}) {
return (
}
columnLabels={
isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes)
? {
column_name: t('Column'),
advanced_data_type: t('Advanced data type'),
type: t('Data type'),
groupby: t('Is dimension'),
is_dttm: t('Is temporal'),
main_dttm_col: t('Default datetime'),
filterable: t('Is filterable'),
}
: {
column_name: t('Column'),
type: t('Data type'),
groupby: t('Is dimension'),
is_dttm: t('Is temporal'),
main_dttm_col: t('Default datetime'),
filterable: t('Is filterable'),
}
}
onChange={onColumnsChange}
itemRenderers={
isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes)
? {
column_name: (v, onItemChange, _, record) =>
editableColumnName ? (
{record.is_certified && (
)}
) : (
{record.is_certified && (
)}
{v}
),
main_dttm_col: (value, _onItemChange, _label, record) => {
const checked = datasource.main_dttm_col === record.column_name;
const disabled = !record?.is_dttm;
return (
onDatasourceChange({
...datasource,
main_dttm_col: record.column_name,
})
}
/>
);
},
type: d => (d ? : null),
advanced_data_type: d => (
),
is_dttm: checkboxGenerator,
filterable: checkboxGenerator,
groupby: checkboxGenerator,
}
: {
column_name: (v, onItemChange, _, record) =>
editableColumnName ? (
{record.is_certified && (
)}
) : (
{record.is_certified && (
)}
{v}
),
main_dttm_col: (value, _onItemChange, _label, record) => {
const checked = datasource.main_dttm_col === record.column_name;
const disabled = !record?.is_dttm;
return (
onDatasourceChange({
...datasource,
main_dttm_col: record.column_name,
})
}
/>
);
},
type: d => (d ? : null),
is_dttm: checkboxGenerator,
filterable: checkboxGenerator,
groupby: checkboxGenerator,
}
}
/>
);
}
ColumnCollectionTable.propTypes = {
columns: PropTypes.array.isRequired,
datasource: PropTypes.object.isRequired,
onColumnsChange: PropTypes.func.isRequired,
onDatasourceChange: PropTypes.func.isRequired,
editableColumnName: PropTypes.bool,
showExpression: PropTypes.bool,
allowAddItem: PropTypes.bool,
allowEditDataType: PropTypes.bool,
itemGenerator: PropTypes.func,
};
ColumnCollectionTable.defaultProps = {
editableColumnName: false,
showExpression: false,
allowAddItem: false,
allowEditDataType: false,
itemGenerator: () => ({
column_name: t(''),
filterable: true,
groupby: true,
}),
};
function StackedField({ label, formElement }) {
return (
);
}
StackedField.propTypes = {
label: PropTypes.string,
formElement: PropTypes.node,
};
function FormContainer({ children }) {
return (
{children}
);
}
FormContainer.propTypes = {
children: PropTypes.node,
};
const propTypes = {
datasource: PropTypes.object.isRequired,
onChange: PropTypes.func,
addSuccessToast: PropTypes.func.isRequired,
addDangerToast: PropTypes.func.isRequired,
setIsEditing: PropTypes.func,
};
const defaultProps = {
onChange: () => {},
setIsEditing: () => {},
};
function OwnersSelector({ datasource, onChange }) {
const loadOptions = useCallback((search = '', page, pageSize) => {
const query = rison.encode({ filter: search, page, page_size: pageSize });
return SupersetClient.get({
endpoint: `/api/v1/dataset/related/owners?q=${query}`,
}).then(response => ({
data: response.json.result
.filter(item => item.extra.active)
.map(item => ({
value: item.value,
label: item.text,
})),
totalCount: response.json.count,
}));
}, []);
return (
{t('Owners')}}
allowClear
/>
);
}
const ResultTable =
extensionsRegistry.get('sqleditor.extension.resultTable') ?? FilterableTable;
class DatasourceEditor extends PureComponent {
constructor(props) {
super(props);
this.state = {
datasource: {
...props.datasource,
owners: props.datasource.owners.map(owner => ({
value: owner.value || owner.id,
label: owner.label || `${owner.first_name} ${owner.last_name}`,
})),
metrics: props.datasource.metrics?.map(metric => {
const {
certified_by: certifiedByMetric,
certification_details: certificationDetails,
} = metric;
const {
certification: { details, certified_by: certifiedBy } = {},
warning_markdown: warningMarkdown,
} = JSON.parse(metric.extra || '{}') || {};
return {
...metric,
certification_details: certificationDetails || details,
warning_markdown: warningMarkdown || '',
certified_by: certifiedBy || certifiedByMetric,
};
}),
},
errors: [],
isSqla:
props.datasource.datasource_type === 'table' ||
props.datasource.type === 'table',
isEditMode: false,
databaseColumns: props.datasource.columns.filter(col => !col.expression),
calculatedColumns: props.datasource.columns.filter(
col => !!col.expression,
),
metadataLoading: false,
activeTabKey: TABS_KEYS.SOURCE,
datasourceType: props.datasource.sql
? DATASOURCE_TYPES.virtual.key
: DATASOURCE_TYPES.physical.key,
};
this.onChange = this.onChange.bind(this);
this.onChangeEditMode = this.onChangeEditMode.bind(this);
this.onDatasourcePropChange = this.onDatasourcePropChange.bind(this);
this.onDatasourceChange = this.onDatasourceChange.bind(this);
this.tableChangeAndSyncMetadata =
this.tableChangeAndSyncMetadata.bind(this);
this.syncMetadata = this.syncMetadata.bind(this);
this.setColumns = this.setColumns.bind(this);
this.validateAndChange = this.validateAndChange.bind(this);
this.handleTabSelect = this.handleTabSelect.bind(this);
this.formatSql = this.formatSql.bind(this);
this.currencies = ensureIsArray(props.currencies).map(currencyCode => ({
value: currencyCode,
label: `${getCurrencySymbol({
symbol: currencyCode,
})} (${currencyCode})`,
}));
}
onChange() {
// Emptying SQL if "Physical" radio button is selected
// Currently the logic to know whether the source is
// physical or virtual is based on whether SQL is empty or not.
const { datasourceType, datasource } = this.state;
const sql =
datasourceType === DATASOURCE_TYPES.physical.key ? '' : datasource.sql;
const newDatasource = {
...this.state.datasource,
sql,
columns: [...this.state.databaseColumns, ...this.state.calculatedColumns],
};
this.props.onChange(newDatasource, this.state.errors);
}
onChangeEditMode() {
this.props.setIsEditing(!this.state.isEditMode);
this.setState(prevState => ({ isEditMode: !prevState.isEditMode }));
}
onDatasourceChange(datasource, callback = this.validateAndChange) {
this.setState({ datasource }, callback);
}
onDatasourcePropChange(attr, value) {
if (value === undefined) return; // if value is undefined do not update state
const datasource = { ...this.state.datasource, [attr]: value };
this.setState(
prevState => ({
datasource: { ...prevState.datasource, [attr]: value },
}),
attr === 'table_name'
? this.onDatasourceChange(datasource, this.tableChangeAndSyncMetadata)
: this.onDatasourceChange(datasource, this.validateAndChange),
);
}
onDatasourceTypeChange(datasourceType) {
// Call onChange after setting datasourceType to ensure
// SQL is cleared when switching to a physical dataset
this.setState({ datasourceType }, this.onChange);
}
setColumns(obj) {
// update calculatedColumns or databaseColumns
this.setState(obj, this.validateAndChange);
}
validateAndChange() {
this.validate(this.onChange);
}
async onQueryRun() {
this.props.runQuery({
client_id: this.props.clientId,
database_id: this.state.datasource.database.id,
json: true,
runAsync: false,
catalog: this.state.datasource.catalog,
schema: this.state.datasource.schema,
sql: this.state.datasource.sql,
tmp_table_name: '',
select_as_cta: false,
ctas_method: 'TABLE',
queryLimit: 25,
expand_data: true,
});
}
async onQueryFormat() {
const { datasource } = this.state;
if (!datasource.sql || !this.state.isEditMode) {
return;
}
try {
const response = await this.props.formatQuery(datasource.sql);
this.onDatasourcePropChange('sql', response.json.result);
this.props.addSuccessToast(t('SQL was formatted'));
} catch (error) {
const { error: clientError, statusText } =
await getClientErrorObject(error);
this.props.addDangerToast(
clientError ||
statusText ||
t('An error occurred while formatting SQL'),
);
}
}
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();
this.onChange();
});
}
async formatSql() {
const { datasource } = this.state;
if (!datasource.sql) {
return;
}
try {
const response = await SupersetClient.post({
endpoint: '/api/v1/sql/format',
body: JSON.stringify({ sql: datasource.sql }),
headers: { 'Content-Type': 'application/json' },
});
this.onDatasourcePropChange('sql', response.json.result);
this.props.addSuccessToast(t('SQL was formatted'));
} catch (error) {
const { error: clientError, statusText } =
await getClientErrorObject(error);
this.props.addDangerToast(
clientError ||
statusText ||
t('An error occurred while formatting SQL'),
);
}
}
async syncMetadata() {
const { datasource } = this.state;
this.setState({ metadataLoading: true });
try {
const newCols = await fetchSyncedColumns(datasource);
const columnChanges = updateColumns(
datasource.columns,
newCols,
this.props.addSuccessToast,
);
this.setColumns({
databaseColumns: columnChanges.finalColumns.filter(
col => !col.expression, // remove calculated columns
),
});
this.props.addSuccessToast(t('Metadata has been synced'));
this.setState({ metadataLoading: false });
} catch (error) {
const { error: clientError, statusText } =
await getClientErrorObject(error);
this.props.addDangerToast(
clientError || statusText || t('An error has occurred'),
);
this.setState({ metadataLoading: false });
}
}
findDuplicates(arr, accessor) {
const seen = {};
const dups = [];
arr.forEach(obj => {
const item = accessor(obj);
if (item in seen) {
dups.push(item);
} else {
seen[item] = null;
}
});
return dups;
}
validate(callback) {
let errors = [];
let dups;
const { datasource } = this.state;
// Looking for duplicate column_name
dups = this.findDuplicates(datasource.columns, obj => obj.column_name);
errors = errors.concat(
dups.map(name => t('Column name [%s] is duplicated', name)),
);
// Looking for duplicate metric_name
dups = this.findDuplicates(datasource.metrics, obj => obj.metric_name);
errors = errors.concat(
dups.map(name => t('Metric name [%s] is duplicated', name)),
);
// Making sure calculatedColumns have an expression defined
const noFilterCalcCols = this.state.calculatedColumns.filter(
col => !col.expression && !col.json,
);
errors = errors.concat(
noFilterCalcCols.map(col =>
t('Calculated column [%s] requires an expression', col.column_name),
),
);
// validate currency code
try {
this.state.datasource.metrics?.forEach(
metric =>
metric.currency?.symbol &&
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: metric.currency.symbol,
}),
);
} catch {
errors = errors.concat([t('Invalid currency code in saved metrics')]);
}
this.setState({ errors }, callback);
}
handleTabSelect(activeTabKey) {
this.setState({ activeTabKey });
}
sortMetrics(metrics) {
return metrics.sort(({ id: a }, { id: b }) => b - a);
}
renderSettingsFieldset() {
const { datasource } = this.state;
return (
);
}
renderAdvancedFieldset() {
const { datasource } = this.state;
return (
);
}
renderSpatialTab() {
const { datasource } = this.state;
const { spatials, all_cols: allCols } = datasource;
return {
key: TABS_KEYS.SPATIAL,
label: ,
children: (
({
name: t(''),
type: t(''),
config: null,
})}
collection={spatials}
allowDeletes
itemRenderers={{
name: (d, onChange) => (
),
config: (v, onChange) => (
),
}}
/>
),
};
}
renderSqlEditorOverlay = () => (
css`
position: absolute;
background: ${theme.colorBgLayout};
align-items: center;
display: flex;
height: 100%;
width: 100%;
justify-content: center;
`}
>
css`
display: block;
margin: ${theme.sizeUnit * 4}px auto;
width: fit-content;
color: ${theme.colorText};
`}
>
{t('We are working on your query')}
);
renderOpenInSqlLabLink(isError = false) {
return (
css`
color: ${isError ? theme.colorErrorText : theme.colorText};
font-size: ${theme.fontSizeSM}px;
text-decoration: underline;
`}
>
{t('Open in SQL lab')}
);
}
renderSqlErrorMessage = () => (
css`
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorErrorText};
`}
>
{this.props.database?.error && t('Error executing query. ')}
{this.renderOpenInSqlLabLink(true)}
{t(' to check for details.')}
);
renderSourceFieldset() {
const { datasource } = this.state;
return (
css`
color: ${theme.colorTextTertiary};
`}
role="button"
tabIndex={0}
onClick={this.onChangeEditMode}
>
{this.state.isEditMode ? (
css`
margin: auto ${theme.sizeUnit}px auto 0;
`}
/>
) : (
({
margin: `auto ${theme.sizeUnit}px auto 0`,
})}
/>
)}
{!this.state.isEditMode && (
{t('Click the lock to make changes.')}
)}
{this.state.isEditMode && (
{t('Click the lock to prevent further changes.')}
)}
css`
margin-top: ${theme.sizeUnit * 3}px;
`}
>
{DATASOURCE_TYPES_ARR.map(type => (
{type.label}
))}
)}
{this.state.datasourceType === DATASOURCE_TYPES.physical.key && (
{this.state.isSqla && (
this.onDatasourcePropChange('catalog', catalog)
: undefined
}
onSchemaChange={
this.state.isEditMode
? schema =>
this.onDatasourcePropChange('schema', schema)
: undefined
}
onDbChange={
this.state.isEditMode
? database =>
this.onDatasourcePropChange(
'database',
database,
)
: undefined
}
onTableSelectChange={
this.state.isEditMode
? table =>
this.onDatasourcePropChange('table_name', table)
: undefined
}
readOnly={!this.state.isEditMode}
/>
}
description={t(
'The pointer to a physical table (or view). Keep in mind that the chart is ' +
'associated to this Superset logical table, and this logical table points ' +
'the physical table referenced here.',
)}
/>
)}
)}
);
}
renderErrors() {
if (this.state.errors.length > 0) {
return (
({ marginBottom: theme.sizeUnit * 4 })}
type="error"
message={
<>
{this.state.errors.map(err => (
{err}
))}
>
}
/>
);
}
return null;
}
renderMetricCollection() {
const { datasource } = this.state;
const { metrics } = datasource;
const sortedMetrics = metrics?.length ? this.sortMetrics(metrics) : [];
return (
}
collection={sortedMetrics}
allowAddItem
onChange={this.onDatasourcePropChange.bind(this, 'metrics')}
itemGenerator={() => ({
metric_name: t(''),
verbose_name: '',
expression: '',
})}
itemCellProps={{
expression: () => ({
width: '240px',
}),
}}
itemRenderers={{
metric_name: (v, onChange, _, record) => (
{record.is_certified && (
)}
{record.warning_markdown && (
)}
),
verbose_name: (v, onChange) => (
),
expression: (v, onChange) => (
),
description: (v, onChange, label) => (
}
/>
),
d3format: (v, onChange, label) => (
}
/>
),
}}
allowDeletes
stickyHeader
/>
);
}
render() {
const { datasource, activeTabKey } = this.state;
const { metrics } = datasource;
const sortedMetrics = metrics?.length ? this.sortMetrics(metrics) : [];
const { theme } = this.props;
return (
{this.renderErrors()}
({ marginBottom: theme.sizeUnit * 4 })}
type="warning"
message={
<>
{' '}
{t('Be careful.')}
{t(
'Changing these settings will affect all charts using this dataset, including charts owned by other people.',
)}
>
}
/>
),
children: this.renderMetricCollection(),
},
{
key: TABS_KEYS.COLUMNS,
label: (
),
children: (
{t('Sync columns from source')}
this.setColumns({ databaseColumns })
}
onDatasourceChange={this.onDatasourceChange}
/>
{this.state.metadataLoading && }
),
},
{
key: TABS_KEYS.CALCULATED_COLUMNS,
label: (
),
children: (
this.setColumns({ calculatedColumns })
}
columnLabelTooltips={{
column_name: t(
'This field is used as a unique identifier to attach ' +
'the calculated dimension to charts. It is also used ' +
'as the alias in the SQL query.',
),
}}
onDatasourceChange={this.onDatasourceChange}
datasource={datasource}
editableColumnName
showExpression
allowAddItem
allowEditDataType
itemGenerator={() => ({
column_name: t(''),
filterable: true,
groupby: true,
expression: t(''),
expanded: true,
})}
/>
),
},
{
key: TABS_KEYS.SETTINGS,
label: t('Settings'),
children: (
{this.renderSettingsFieldset()}
{this.renderAdvancedFieldset()}
),
},
]}
/>
);
}
componentDidMount() {
Mousetrap.bind('ctrl+shift+f', e => {
e.preventDefault();
if (this.state.isEditMode) {
this.onQueryFormat();
}
return false;
});
}
componentWillUnmount() {
Mousetrap.unbind('ctrl+shift+f');
this.props.resetQuery();
}
}
DatasourceEditor.defaultProps = defaultProps;
DatasourceEditor.propTypes = propTypes;
const DataSourceComponent = withTheme(DatasourceEditor);
const mapDispatchToProps = dispatch => ({
runQuery: payload => dispatch(executeQuery(payload)),
resetQuery: () => dispatch(resetDatabaseState()),
formatQuery: sql => dispatch(formatQuery(sql)),
});
const mapStateToProps = state => ({
database: state?.database,
});
export default withToasts(
connect(mapStateToProps, mapDispatchToProps)(DataSourceComponent),
);