/**
* 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 React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { Radio } from 'src/components/Radio';
import Card from 'src/components/Card';
import Alert from 'src/components/Alert';
import Badge from 'src/components/Badge';
import shortid from 'shortid';
import {
css,
isFeatureEnabled,
getCurrencySymbol,
ensureIsArray,
FeatureFlag,
styled,
SupersetClient,
t,
withTheme,
} from '@superset-ui/core';
import { Select, AsyncSelect, Row, Col } from 'src/components';
import { FormLabel } from 'src/components/Form';
import Button from 'src/components/Button';
import Tabs from 'src/components/Tabs';
import CertifiedBadge from 'src/components/CertifiedBadge';
import WarningIconWithTooltip from 'src/components/WarningIconWithTooltip';
import DatabaseSelector from 'src/components/DatabaseSelector';
import Label from 'src/components/Label';
import Loading from 'src/components/Loading';
import TableSelector from 'src/components/TableSelector';
import EditableTitle from 'src/components/EditableTitle';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
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 Icons from 'src/components/Icons';
import CurrencyControl from 'src/explore/components/controls/CurrencyControl';
import CollectionTable from './CollectionTable';
import Fieldset from './Fieldset';
import Field from './Field';
const DatasourceContainer = styled.div`
.change-warning {
margin: 16px 10px 0;
color: ${({ theme }) => theme.colors.warning.base};
}
.change-warning .bold {
font-weight: ${({ theme }) => theme.typography.weights.bold};
}
.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.gridUnit}px;
}
`;
const StyledTableTabs = styled(Tabs)`
overflow: visible;
.ant-tabs-content-holder {
overflow: visible;
}
`;
const StyledBadge = styled(Badge)`
.ant-badge-count {
line-height: ${({ theme }) => theme.gridUnit * 4}px;
height: ${({ theme }) => theme.gridUnit * 4}px;
margin-left: ${({ theme }) => theme.gridUnit}px;
}
`;
const EditLockContainer = styled.div`
font-size: ${({ theme }) => theme.typography.sizes.s}px;
display: flex;
align-items: center;
a {
padding: 0 10px;
}
`;
const ColumnButtonWrapper = styled.div`
text-align: right;
${({ theme }) => `margin-bottom: ${theme.gridUnit * 2}px`}
`;
const StyledLabelWrapper = styled.div`
display: flex;
align-items: center;
span {
margin-right: ${({ theme }) => theme.gridUnit}px;
}
`;
const StyledColumnsTabWrapper = styled.div`
.table > tbody > tr > td {
vertical-align: middle;
}
.ant-tag {
margin-top: ${({ theme }) => theme.gridUnit}px;
}
`;
const StyledButtonWrapper = styled.span`
${({ theme }) => `
margin-top: ${theme.gridUnit * 3}px;
margin-left: ${theme.gridUnit * 3}px;
`}
`;
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 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.ENABLE_ADVANCED_DATA_TYPES)
? {
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.ENABLE_ADVANCED_DATA_TYPES)
? {
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 = !columns.find(
column => column.column_name === record.column_name,
).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 = !columns.find(
column => column.column_name === record.column_name,
).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
/>
);
}
class DatasourceEditor extends React.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: 0,
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.allowEditSource = !isFeatureEnabled(
FeatureFlag.DISABLE_DATASET_SOURCE_EDIT,
);
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) {
this.setState({ datasourceType });
}
setColumns(obj) {
// update calculatedColumns or databaseColumns
this.setState(obj, this.validateAndChange);
}
validateAndChange() {
this.validate(this.onChange);
}
tableChangeAndSyncMetadata() {
this.validate(() => {
this.syncMetadata();
this.onChange();
});
}
updateColumns(cols) {
// cols: Array<{column_name: string; is_dttm: boolean; type: string;}>
const { databaseColumns } = this.state;
const databaseColumnNames = cols.map(col => col.column_name);
const currentCols = databaseColumns.reduce(
(agg, col) => ({
...agg,
[col.column_name]: col,
}),
{},
);
const finalColumns = [];
const results = {
added: [],
modified: [],
removed: databaseColumns
.map(col => col.column_name)
.filter(col => !databaseColumnNames.includes(col)),
};
cols.forEach(col => {
const currentCol = currentCols[col.column_name];
if (!currentCol) {
// new column
finalColumns.push({
id: shortid.generate(),
column_name: col.column_name,
type: col.type,
groupby: true,
filterable: true,
is_dttm: col.is_dttm,
});
results.added.push(col.column_name);
} else if (
currentCol.type !== col.type ||
(!currentCol.is_dttm && col.is_dttm)
) {
// modified column
finalColumns.push({
...currentCol,
type: col.type,
is_dttm: currentCol.is_dttm || col.is_dttm,
});
results.modified.push(col.column_name);
} else {
// unchanged
finalColumns.push(currentCol);
}
});
if (
results.added.length ||
results.modified.length ||
results.removed.length
) {
this.setColumns({ databaseColumns: finalColumns });
}
return results;
}
syncMetadata() {
const { datasource } = this.state;
const params = {
datasource_type: datasource.type || datasource.datasource_type,
database_name:
datasource.database.database_name || datasource.database.name,
schema_name: datasource.schema,
table_name: datasource.table_name,
normalize_columns: datasource.normalize_columns,
};
Object.entries(params).forEach(([key, value]) => {
// rison can't encode the undefined value
if (value === undefined) {
params[key] = null;
}
});
const endpoint = `/datasource/external_metadata_by_name/?q=${rison.encode_uri(
params,
)}`;
this.setState({ metadataLoading: true });
SupersetClient.get({ endpoint })
.then(({ json }) => {
const results = this.updateColumns(json);
if (results.modified.length) {
this.props.addSuccessToast(
t('Modified columns: %s', results.modified.join(', ')),
);
}
if (results.removed.length) {
this.props.addSuccessToast(
t('Removed columns: %s', results.removed.join(', ')),
);
}
if (results.added.length) {
this.props.addSuccessToast(
t('New columns added: %s', results.added.join(', ')),
);
}
this.props.addSuccessToast(t('Metadata has been synced'));
this.setState({ metadataLoading: false });
})
.catch(response =>
getClientErrorObject(response).then(({ error, statusText }) => {
this.props.addDangerToast(
error || 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={4}
>
({
name: t(''),
type: t(''),
config: null,
})}
collection={spatials}
allowDeletes
itemRenderers={{
name: (d, onChange) => (
),
config: (v, onChange) => (
),
}}
/>
);
}
renderSourceFieldset(theme) {
const { datasource } = this.state;
return (
{this.allowEditSource && (
{this.state.isEditMode ? (
) : (
)}
{!this.state.isEditMode && (
{t('Click the lock to make changes.')}
)}
{this.state.isEditMode && (
{t('Click the lock to prevent further changes.')}
)}
)}
{DATASOURCE_TYPES_ARR.map(type => (
{type.label}
))}
)}
{this.state.datasourceType === DATASOURCE_TYPES.physical.key && (
{this.state.isSqla && (
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.gridUnit * 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.gridUnit * 4 })}
type="warning"
message={
<>
{' '}
{t('Be careful.')}
{t(
'Changing these settings will affect all charts using this dataset, including charts owned by other people.',
)}
>
}
/>
{this.renderSourceFieldset(theme)}
}
key={1}
>
{this.renderMetricCollection()}
}
key={2}
>
this.setColumns({ databaseColumns })
}
onDatasourceChange={this.onDatasourceChange}
/>
{this.state.metadataLoading && }
}
key={3}
>
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,
})}
/>
{this.renderSettingsFieldset()}
{this.renderAdvancedFieldset()}
);
}
}
DatasourceEditor.defaultProps = defaultProps;
DatasourceEditor.propTypes = propTypes;
const DataSourceComponent = withTheme(DatasourceEditor);
export default withToasts(DataSourceComponent);