mirror of
https://github.com/apache/superset.git
synced 2026-05-06 16:34:32 +00:00
## Changes - Added default_temporal_column property to backend model for X-axis defaults - Added temporal column selector in Dataset Editor UI (datetime columns only) - Integrated temporal column application in chart creation flow - Added comprehensive test coverage for all new functionality ## Test Coverage Added ### Backend Tests (7 new tests) - Properties correctly parse JSON from extra field - Invalid/null JSON handling - set_default_chart_metadata functionality - Preservation of other extra field data ### Frontend Tests (15+ new tests) - applyDatasetChartDefaults function unit tests - Validation of metrics, dimensions, and temporal columns - Error handling for malformed data - DatasourceEditor component tests - Integration with existing hydrateExplore tests ## Test Results ✅ All backend tests passing (pytest) ✅ All frontend tests passing (Jest) ✅ Pre-commit checks passing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2081 lines
67 KiB
JavaScript
2081 lines
67 KiB
JavaScript
/**
|
|
* 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,
|
|
Form,
|
|
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 DateFilterLabel from 'src/explore/components/controls/DateFilterControl/DateFilterLabel';
|
|
import AdhocFilterControl from 'src/explore/components/controls/FilterControl/AdhocFilterControl';
|
|
import { DatabaseSelector } from '../DatabaseSelector';
|
|
import CollectionTable from './CollectionTable';
|
|
import Fieldset from './Fieldset';
|
|
import Field from './Field';
|
|
import { fetchSyncedColumns, updateColumns } from './utils';
|
|
|
|
const extensionsRegistry = getExtensionsRegistry();
|
|
|
|
// Helper function to safely parse extra field
|
|
const parseExtra = extra => {
|
|
if (!extra) return {};
|
|
if (typeof extra === 'object') return extra;
|
|
try {
|
|
return JSON.parse(extra);
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
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) => (
|
|
<CheckboxControl value={d} onChange={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 (
|
|
<div
|
|
css={{ display: 'flex', alignItems: 'center' }}
|
|
data-test={`collection-tab-${title}`}
|
|
>
|
|
{title}{' '}
|
|
<StyledBadge count={collection ? collection.length : 0} showZero />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
CollectionTabTitle.propTypes = {
|
|
title: PropTypes.string,
|
|
collection: PropTypes.array,
|
|
};
|
|
|
|
function ColumnCollectionTable({
|
|
columns,
|
|
datasource,
|
|
onColumnsChange,
|
|
onDatasourceChange,
|
|
editableColumnName,
|
|
showExpression,
|
|
allowAddItem,
|
|
allowEditDataType,
|
|
itemGenerator,
|
|
columnLabelTooltips,
|
|
}) {
|
|
return (
|
|
<CollectionTable
|
|
tableColumns={
|
|
isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes)
|
|
? [
|
|
'column_name',
|
|
'advanced_data_type',
|
|
'type',
|
|
'is_dttm',
|
|
'main_dttm_col',
|
|
'filterable',
|
|
'groupby',
|
|
]
|
|
: [
|
|
'column_name',
|
|
'type',
|
|
'is_dttm',
|
|
'main_dttm_col',
|
|
'filterable',
|
|
'groupby',
|
|
]
|
|
}
|
|
sortColumns={
|
|
isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes)
|
|
? [
|
|
'column_name',
|
|
'advanced_data_type',
|
|
'type',
|
|
'is_dttm',
|
|
'main_dttm_col',
|
|
'filterable',
|
|
'groupby',
|
|
]
|
|
: [
|
|
'column_name',
|
|
'type',
|
|
'is_dttm',
|
|
'main_dttm_col',
|
|
'filterable',
|
|
'groupby',
|
|
]
|
|
}
|
|
allowDeletes
|
|
allowAddItem={allowAddItem}
|
|
itemGenerator={itemGenerator}
|
|
collection={columns}
|
|
columnLabelTooltips={columnLabelTooltips}
|
|
stickyHeader
|
|
expandFieldset={
|
|
<FormContainer>
|
|
<Fieldset compact>
|
|
{showExpression && (
|
|
<Field
|
|
fieldKey="expression"
|
|
label={t('SQL expression')}
|
|
control={
|
|
<TextAreaControl
|
|
language="markdown"
|
|
offerEditInModal={false}
|
|
resize="vertical"
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
<Field
|
|
fieldKey="verbose_name"
|
|
label={t('Label')}
|
|
control={
|
|
<TextControl
|
|
controlId="verbose_name"
|
|
placeholder={t('Label')}
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
fieldKey="description"
|
|
label={t('Description')}
|
|
control={
|
|
<TextControl
|
|
controlId="description"
|
|
placeholder={t('Description')}
|
|
/>
|
|
}
|
|
/>
|
|
{allowEditDataType && (
|
|
<Field
|
|
fieldKey="type"
|
|
label={t('Data type')}
|
|
control={
|
|
<Select
|
|
ariaLabel={t('Data type')}
|
|
options={DATA_TYPES}
|
|
name="type"
|
|
allowNewOptions
|
|
allowClear
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
{isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes) ? (
|
|
<Field
|
|
fieldKey="advanced_data_type"
|
|
label={t('Advanced data type')}
|
|
control={
|
|
<TextControl
|
|
controlId="advanced_data_type"
|
|
placeholder={t('Advanced Data type')}
|
|
/>
|
|
}
|
|
/>
|
|
) : (
|
|
<></>
|
|
)}
|
|
<Field
|
|
fieldKey="python_date_format"
|
|
label={t('Datetime format')}
|
|
description={
|
|
/* Note the fragmented translations may not work. */
|
|
<div>
|
|
{t('The pattern of timestamp format. For strings use ')}
|
|
<Typography.Link href="https://docs.python.org/2/library/datetime.html#strftime-strptime-behavior">
|
|
{t('Python datetime string pattern')}
|
|
</Typography.Link>
|
|
{t(' expression which needs to adhere to the ')}
|
|
<Typography.Link href="https://en.wikipedia.org/wiki/ISO_8601">
|
|
{t('ISO 8601')}
|
|
</Typography.Link>
|
|
{t(` standard to ensure that the lexicographical ordering
|
|
coincides with the chronological ordering. If the
|
|
timestamp format does not adhere to the ISO 8601 standard
|
|
you will need to define an expression and type for
|
|
transforming the string into a date or timestamp. Note
|
|
currently time zones are not supported. If time is stored
|
|
in epoch format, put \`epoch_s\` or \`epoch_ms\`. If no pattern
|
|
is specified we fall back to using the optional defaults on a per
|
|
database/column name level via the extra parameter.`)}
|
|
</div>
|
|
}
|
|
control={
|
|
<TextControl
|
|
controlId="python_date_format"
|
|
placeholder="%Y-%m-%d"
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
fieldKey="certified_by"
|
|
label={t('Certified By')}
|
|
description={t('Person or group that has certified this metric')}
|
|
control={
|
|
<TextControl
|
|
controlId="certified"
|
|
placeholder={t('Certified by')}
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
fieldKey="certification_details"
|
|
label={t('Certification details')}
|
|
description={t('Details of the certification')}
|
|
control={
|
|
<TextControl
|
|
controlId="certificationDetails"
|
|
placeholder={t('Certification details')}
|
|
/>
|
|
}
|
|
/>
|
|
</Fieldset>
|
|
</FormContainer>
|
|
}
|
|
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 ? (
|
|
<StyledLabelWrapper>
|
|
{record.is_certified && (
|
|
<CertifiedBadge
|
|
certifiedBy={record.certified_by}
|
|
details={record.certification_details}
|
|
/>
|
|
)}
|
|
<EditableTitle
|
|
canEdit
|
|
title={v}
|
|
onSaveTitle={onItemChange}
|
|
/>
|
|
</StyledLabelWrapper>
|
|
) : (
|
|
<StyledLabelWrapper>
|
|
{record.is_certified && (
|
|
<CertifiedBadge
|
|
certifiedBy={record.certified_by}
|
|
details={record.certification_details}
|
|
/>
|
|
)}
|
|
{v}
|
|
</StyledLabelWrapper>
|
|
),
|
|
main_dttm_col: (value, _onItemChange, _label, record) => {
|
|
const checked = datasource.main_dttm_col === record.column_name;
|
|
const disabled = !record?.is_dttm;
|
|
return (
|
|
<Radio
|
|
aria-label={t(
|
|
'Set %s as default datetime column',
|
|
record.column_name,
|
|
)}
|
|
data-test={`radio-default-dttm-${record.column_name}`}
|
|
checked={checked}
|
|
disabled={disabled}
|
|
onChange={() =>
|
|
onDatasourceChange({
|
|
...datasource,
|
|
main_dttm_col: record.column_name,
|
|
})
|
|
}
|
|
/>
|
|
);
|
|
},
|
|
type: d => (d ? <Label>{d}</Label> : null),
|
|
advanced_data_type: d => (
|
|
<Label onChange={onColumnsChange}>{d}</Label>
|
|
),
|
|
is_dttm: checkboxGenerator,
|
|
filterable: checkboxGenerator,
|
|
groupby: checkboxGenerator,
|
|
}
|
|
: {
|
|
column_name: (v, onItemChange, _, record) =>
|
|
editableColumnName ? (
|
|
<StyledLabelWrapper>
|
|
{record.is_certified && (
|
|
<CertifiedBadge
|
|
certifiedBy={record.certified_by}
|
|
details={record.certification_details}
|
|
/>
|
|
)}
|
|
<TextControl value={v} onChange={onItemChange} />
|
|
</StyledLabelWrapper>
|
|
) : (
|
|
<StyledLabelWrapper>
|
|
{record.is_certified && (
|
|
<CertifiedBadge
|
|
certifiedBy={record.certified_by}
|
|
details={record.certification_details}
|
|
/>
|
|
)}
|
|
{v}
|
|
</StyledLabelWrapper>
|
|
),
|
|
main_dttm_col: (value, _onItemChange, _label, record) => {
|
|
const checked = datasource.main_dttm_col === record.column_name;
|
|
const disabled = !record?.is_dttm;
|
|
return (
|
|
<Radio
|
|
aria-label={t(
|
|
'Set %s as default datetime column',
|
|
record.column_name,
|
|
)}
|
|
data-test={`radio-default-dttm-${record.column_name}`}
|
|
checked={checked}
|
|
disabled={disabled}
|
|
onChange={() =>
|
|
onDatasourceChange({
|
|
...datasource,
|
|
main_dttm_col: record.column_name,
|
|
})
|
|
}
|
|
/>
|
|
);
|
|
},
|
|
type: d => (d ? <Label>{d}</Label> : 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('<new column>'),
|
|
filterable: true,
|
|
groupby: true,
|
|
}),
|
|
};
|
|
|
|
function StackedField({ label, formElement }) {
|
|
return (
|
|
<div>
|
|
<div>
|
|
<strong>{label}</strong>
|
|
</div>
|
|
<div>{formElement}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
StackedField.propTypes = {
|
|
label: PropTypes.string,
|
|
formElement: PropTypes.node,
|
|
};
|
|
|
|
function FormContainer({ children }) {
|
|
return (
|
|
<Card padded style={{ backgroundColor: themeObject.theme.colorBgLayout }}>
|
|
{children}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<AsyncSelect
|
|
ariaLabel={t('Select owners')}
|
|
mode="multiple"
|
|
name="owners"
|
|
value={datasource.owners}
|
|
options={loadOptions}
|
|
onChange={onChange}
|
|
header={<FormLabel>{t('Owners')}</FormLabel>}
|
|
allowClear
|
|
/>
|
|
);
|
|
}
|
|
const ResultTable =
|
|
extensionsRegistry.get('sqleditor.extension.resultTable') ?? FilterableTable;
|
|
|
|
class DatasourceEditor extends PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
datasource: {
|
|
...props.datasource,
|
|
extra: props.datasource.extra || '{}', // Initialize null extra as empty JSON object
|
|
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.onChartDefaultChange = this.onChartDefaultChange.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),
|
|
);
|
|
}
|
|
|
|
// Helper method to update chart defaults in extra field
|
|
onChartDefaultChange(defaultKey, value) {
|
|
const extra = { ...parseExtra(this.state.datasource.extra) };
|
|
if (!extra.default_chart_metadata) {
|
|
extra.default_chart_metadata = {};
|
|
}
|
|
extra.default_chart_metadata[defaultKey] = value;
|
|
this.onDatasourcePropChange('extra', JSON.stringify(extra));
|
|
}
|
|
|
|
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,
|
|
);
|
|
|
|
// Update columns
|
|
const updatedDatabaseColumns = columnChanges.finalColumns.filter(
|
|
col => !col.expression, // remove calculated columns
|
|
);
|
|
this.setColumns({
|
|
databaseColumns: updatedDatabaseColumns,
|
|
});
|
|
|
|
// Clean up chart defaults that may reference removed columns
|
|
this.cleanupChartDefaults(updatedDatabaseColumns, columnChanges.removed);
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
cleanupChartDefaults(updatedColumns, removedColumnNames) {
|
|
const { datasource } = this.state;
|
|
if (!datasource.extra) return;
|
|
|
|
try {
|
|
const extra = JSON.parse(datasource.extra);
|
|
const chartDefaults = extra.default_chart_metadata || {};
|
|
let needsUpdate = false;
|
|
|
|
// Check if default dimension was removed
|
|
if (
|
|
chartDefaults.default_dimension &&
|
|
removedColumnNames.includes(chartDefaults.default_dimension)
|
|
) {
|
|
delete chartDefaults.default_dimension;
|
|
needsUpdate = true;
|
|
this.props.addDangerToast(
|
|
t(
|
|
'Default dimension "%s" was removed during metadata sync',
|
|
chartDefaults.default_dimension,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Check if default temporal column was removed
|
|
if (
|
|
chartDefaults.default_temporal_column &&
|
|
removedColumnNames.includes(chartDefaults.default_temporal_column)
|
|
) {
|
|
delete chartDefaults.default_temporal_column;
|
|
needsUpdate = true;
|
|
this.props.addDangerToast(
|
|
t(
|
|
'Default temporal column "%s" was removed during metadata sync',
|
|
chartDefaults.default_temporal_column,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Note: We don't check metrics here since they're not part of column sync
|
|
// Metrics are managed separately and won't be affected by column metadata sync
|
|
|
|
if (needsUpdate) {
|
|
extra.default_chart_metadata = chartDefaults;
|
|
this.onDatasourcePropChange('extra', JSON.stringify(extra));
|
|
}
|
|
} catch (error) {
|
|
// Ignore JSON parsing errors
|
|
console.warn(
|
|
'Failed to parse dataset extra during chart defaults cleanup:',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<Fieldset
|
|
title={t('Basic')}
|
|
item={datasource}
|
|
onChange={this.onDatasourceChange}
|
|
>
|
|
<Field
|
|
fieldKey="description"
|
|
label={t('Description')}
|
|
control={
|
|
<TextAreaControl
|
|
language="markdown"
|
|
offerEditInModal={false}
|
|
resize="vertical"
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
fieldKey="default_endpoint"
|
|
label={t('Default URL')}
|
|
description={t(
|
|
`Default URL to redirect to when accessing from the dataset list page.
|
|
Accepts relative URLs such as <span style=„white-space: nowrap;”>/superset/dashboard/{id}/</span>`,
|
|
)}
|
|
control={<TextControl controlId="default_endpoint" />}
|
|
/>
|
|
<Field
|
|
inline
|
|
fieldKey="filter_select_enabled"
|
|
label={t('Autocomplete filters')}
|
|
description={t('Whether to populate autocomplete filters options')}
|
|
control={<CheckboxControl />}
|
|
/>
|
|
{this.state.isSqla && (
|
|
<Field
|
|
fieldKey="fetch_values_predicate"
|
|
label={t('Autocomplete query predicate')}
|
|
description={t(
|
|
'When using "Autocomplete filters", this can be used to improve performance ' +
|
|
'of the query fetching the values. Use this option to apply a ' +
|
|
'predicate (WHERE clause) to the query selecting the distinct ' +
|
|
'values from the table. Typically the intent would be to limit the scan ' +
|
|
'by applying a relative time filter on a partitioned or indexed time-related field.',
|
|
)}
|
|
control={
|
|
<TextAreaControl
|
|
language="sql"
|
|
controlId="fetch_values_predicate"
|
|
minLines={5}
|
|
resize="vertical"
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
{this.state.isSqla && (
|
|
<Field
|
|
fieldKey="extra"
|
|
label={t('Extra')}
|
|
description={t(
|
|
'Extra data to specify table metadata. Currently supports ' +
|
|
'metadata of the format: `{ "certification": { "certified_by": ' +
|
|
'"Data Platform Team", "details": "This table is the source of truth." ' +
|
|
'}, "warning_markdown": "This is a warning." }`.',
|
|
)}
|
|
control={
|
|
<TextAreaControl
|
|
controlId="extra"
|
|
language="json"
|
|
offerEditInModal={false}
|
|
resize="vertical"
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
<OwnersSelector
|
|
datasource={datasource}
|
|
onChange={newOwners => {
|
|
this.onDatasourceChange({ ...datasource, owners: newOwners });
|
|
}}
|
|
/>
|
|
</Fieldset>
|
|
<Form.Item>
|
|
<Typography.Title level={5}>
|
|
{t('Chart Defaults')} <Divider />
|
|
</Typography.Title>
|
|
<Field
|
|
fieldKey="default_metric"
|
|
label={t('Default Metric')}
|
|
description={t(
|
|
'Pre-populate this metric when creating new charts from this dataset',
|
|
)}
|
|
value={
|
|
parseExtra(datasource.extra).default_chart_metadata
|
|
?.default_metric
|
|
}
|
|
onChange={(fieldKey, value) =>
|
|
this.onChartDefaultChange('default_metric', value)
|
|
}
|
|
control={
|
|
<Select
|
|
name="default_metric"
|
|
options={
|
|
datasource?.metrics?.map(metric => ({
|
|
value: metric.metric_name,
|
|
label: metric.verbose_name || metric.metric_name,
|
|
})) || []
|
|
}
|
|
placeholder={t('Select a metric')}
|
|
allowClear
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
fieldKey="default_dimension"
|
|
label={t('Default Dimension')}
|
|
description={t(
|
|
'Pre-populate this dimension/groupby when creating new charts from this dataset',
|
|
)}
|
|
value={
|
|
parseExtra(datasource.extra).default_chart_metadata
|
|
?.default_dimension
|
|
}
|
|
onChange={(fieldKey, value) =>
|
|
this.onChartDefaultChange('default_dimension', value)
|
|
}
|
|
control={
|
|
<Select
|
|
name="default_dimension"
|
|
options={
|
|
datasource?.columns
|
|
?.filter(col => col.groupby)
|
|
?.map(column => ({
|
|
value: column.column_name,
|
|
label: column.verbose_name || column.column_name,
|
|
})) || []
|
|
}
|
|
placeholder={t('Select a dimension')}
|
|
allowClear
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
fieldKey="default_temporal_column"
|
|
label={t('Default Temporal Column')}
|
|
description={t(
|
|
'Pre-populate this temporal column/X-axis when creating new charts from this dataset',
|
|
)}
|
|
value={
|
|
parseExtra(datasource.extra).default_chart_metadata
|
|
?.default_temporal_column
|
|
}
|
|
onChange={(fieldKey, value) =>
|
|
this.onChartDefaultChange('default_temporal_column', value)
|
|
}
|
|
control={
|
|
<Select
|
|
name="default_temporal_column"
|
|
options={
|
|
datasource?.columns
|
|
?.filter(col => col.is_dttm)
|
|
?.map(column => ({
|
|
value: column.column_name,
|
|
label: column.verbose_name || column.column_name,
|
|
})) || []
|
|
}
|
|
placeholder={t('Select a temporal column')}
|
|
allowClear
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
fieldKey="default_time_grain"
|
|
label={t('Default Time Grain')}
|
|
description={t(
|
|
'Pre-populate this time grain when creating new charts from this dataset. ' +
|
|
'Options are database-specific.',
|
|
)}
|
|
value={
|
|
parseExtra(datasource.extra).default_chart_metadata
|
|
?.default_time_grain
|
|
}
|
|
onChange={(fieldKey, value) =>
|
|
this.onChartDefaultChange('default_time_grain', value)
|
|
}
|
|
control={
|
|
<Select
|
|
name="default_time_grain"
|
|
options={
|
|
datasource.time_grain_sqla?.map(([value, label]) => ({
|
|
value,
|
|
label,
|
|
})) || []
|
|
}
|
|
placeholder={t('Select a time grain')}
|
|
allowClear
|
|
/>
|
|
}
|
|
/>
|
|
<Form.Item
|
|
label={t('Default Time Range')}
|
|
extra={t(
|
|
'Pre-populate this time range when creating new charts from this dataset',
|
|
)}
|
|
>
|
|
<DateFilterLabel
|
|
name="default_time_range"
|
|
onChange={value =>
|
|
this.onChartDefaultChange('default_time_range', value)
|
|
}
|
|
value={
|
|
parseExtra(datasource.extra).default_chart_metadata
|
|
?.default_time_range || 'No filter'
|
|
}
|
|
overlayStyle="Modal"
|
|
/>
|
|
</Form.Item>
|
|
<Field
|
|
fieldKey="default_row_limit"
|
|
label={t('Default Row Limit')}
|
|
description={t(
|
|
'Pre-populate this row limit when creating new charts from this dataset',
|
|
)}
|
|
value={
|
|
parseExtra(datasource.extra).default_chart_metadata
|
|
?.default_row_limit
|
|
}
|
|
onChange={(fieldKey, value) =>
|
|
this.onChartDefaultChange(
|
|
'default_row_limit',
|
|
value ? parseInt(value, 10) : null,
|
|
)
|
|
}
|
|
control={
|
|
<TextControl
|
|
name="default_row_limit"
|
|
placeholder={t('e.g., 1000')}
|
|
type="number"
|
|
/>
|
|
}
|
|
/>
|
|
<Form.Item
|
|
label={t('Default Filters')}
|
|
extra={t(
|
|
'Pre-populate these filters when creating new charts from this dataset',
|
|
)}
|
|
style={{ marginTop: '16px' }}
|
|
>
|
|
<AdhocFilterControl
|
|
name="default_filters"
|
|
onChange={filters =>
|
|
this.onChartDefaultChange('default_filters', filters)
|
|
}
|
|
value={
|
|
parseExtra(datasource.extra).default_chart_metadata
|
|
?.default_filters || []
|
|
}
|
|
datasource={datasource}
|
|
columns={datasource.columns}
|
|
savedMetrics={datasource.metrics}
|
|
/>
|
|
</Form.Item>
|
|
</Form.Item>
|
|
</>
|
|
);
|
|
}
|
|
|
|
renderAdvancedFieldset() {
|
|
const { datasource } = this.state;
|
|
return (
|
|
<Fieldset
|
|
title={t('Advanced')}
|
|
item={datasource}
|
|
onChange={this.onDatasourceChange}
|
|
>
|
|
<Field
|
|
fieldKey="cache_timeout"
|
|
label={t('Cache timeout')}
|
|
description={t(
|
|
'The duration of time in seconds before the cache is invalidated. Set to -1 to bypass the cache.',
|
|
)}
|
|
control={<TextControl controlId="cache_timeout" />}
|
|
/>
|
|
<Field
|
|
fieldKey="offset"
|
|
label={t('Hours offset')}
|
|
control={<TextControl controlId="offset" />}
|
|
description={t(
|
|
'The number of hours, negative or positive, to shift the time column. This can be used to move UTC time to local time.',
|
|
)}
|
|
/>
|
|
{this.state.isSqla && (
|
|
<Field
|
|
fieldKey="template_params"
|
|
label={t('Template parameters')}
|
|
description={t(
|
|
'A set of parameters that become available in the query using Jinja templating syntax',
|
|
)}
|
|
control={<TextControl controlId="template_params" />}
|
|
/>
|
|
)}
|
|
<Field
|
|
inline
|
|
fieldKey="normalize_columns"
|
|
label={t('Normalize column names')}
|
|
description={t(
|
|
'Allow column names to be changed to case insensitive format, if supported (e.g. Oracle, Snowflake).',
|
|
)}
|
|
control={<CheckboxControl controlId="normalize_columns" />}
|
|
/>
|
|
<Field
|
|
inline
|
|
fieldKey="always_filter_main_dttm"
|
|
label={t('Always filter main datetime column')}
|
|
description={t(
|
|
`When the secondary temporal columns are filtered, apply the same filter to the main datetime column.`,
|
|
)}
|
|
control={<CheckboxControl controlId="always_filter_main_dttm" />}
|
|
/>
|
|
</Fieldset>
|
|
);
|
|
}
|
|
|
|
renderSpatialTab() {
|
|
const { datasource } = this.state;
|
|
const { spatials, all_cols: allCols } = datasource;
|
|
|
|
return {
|
|
key: TABS_KEYS.SPATIAL,
|
|
label: <CollectionTabTitle collection={spatials} title={t('Spatial')} />,
|
|
children: (
|
|
<CollectionTable
|
|
tableColumns={['name', 'config']}
|
|
onChange={this.onDatasourcePropChange.bind(this, 'spatials')}
|
|
itemGenerator={() => ({
|
|
name: t('<new spatial>'),
|
|
type: t('<no type>'),
|
|
config: null,
|
|
})}
|
|
collection={spatials}
|
|
allowDeletes
|
|
itemRenderers={{
|
|
name: (d, onChange) => (
|
|
<EditableTitle canEdit title={d} onSaveTitle={onChange} />
|
|
),
|
|
config: (v, onChange) => (
|
|
<SpatialControl value={v} onChange={onChange} choices={allCols} />
|
|
),
|
|
}}
|
|
/>
|
|
),
|
|
};
|
|
}
|
|
|
|
renderSqlEditorOverlay = () => (
|
|
<div
|
|
css={theme => css`
|
|
position: absolute;
|
|
background: ${theme.colorBgLayout};
|
|
align-items: center;
|
|
display: flex;
|
|
height: 100%;
|
|
width: 100%;
|
|
justify-content: center;
|
|
`}
|
|
>
|
|
<div>
|
|
<Loading position="inline-centered" />
|
|
<span
|
|
css={theme => css`
|
|
display: block;
|
|
margin: ${theme.sizeUnit * 4}px auto;
|
|
width: fit-content;
|
|
color: ${theme.colorText};
|
|
`}
|
|
>
|
|
{t('We are working on your query')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
renderOpenInSqlLabLink(isError = false) {
|
|
return (
|
|
<a
|
|
href={this.getSQLLabUrl()}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
css={theme => css`
|
|
color: ${isError ? theme.colorErrorText : theme.colorText};
|
|
font-size: ${theme.fontSizeSM}px;
|
|
text-decoration: underline;
|
|
`}
|
|
>
|
|
{t('Open in SQL lab')}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
renderSqlErrorMessage = () => (
|
|
<span
|
|
css={theme => 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.')}
|
|
</span>
|
|
);
|
|
|
|
renderSourceFieldset() {
|
|
const { datasource } = this.state;
|
|
|
|
return (
|
|
<div>
|
|
<EditLockContainer>
|
|
<span
|
|
css={theme => css`
|
|
color: ${theme.colorTextTertiary};
|
|
`}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={this.onChangeEditMode}
|
|
>
|
|
{this.state.isEditMode ? (
|
|
<Icons.UnlockOutlined
|
|
iconSize="xl"
|
|
css={theme => css`
|
|
margin: auto ${theme.sizeUnit}px auto 0;
|
|
`}
|
|
/>
|
|
) : (
|
|
<Icons.LockOutlined
|
|
iconSize="xl"
|
|
css={theme => ({
|
|
margin: `auto ${theme.sizeUnit}px auto 0`,
|
|
})}
|
|
/>
|
|
)}
|
|
</span>
|
|
{!this.state.isEditMode && (
|
|
<div>{t('Click the lock to make changes.')}</div>
|
|
)}
|
|
{this.state.isEditMode && (
|
|
<div>{t('Click the lock to prevent further changes.')}</div>
|
|
)}
|
|
</EditLockContainer>
|
|
<div
|
|
css={theme => css`
|
|
margin-top: ${theme.sizeUnit * 3}px;
|
|
`}
|
|
>
|
|
{DATASOURCE_TYPES_ARR.map(type => (
|
|
<Radio
|
|
key={type.key}
|
|
value={type.key}
|
|
inline
|
|
onChange={this.onDatasourceTypeChange.bind(this, type.key)}
|
|
checked={this.state.datasourceType === type.key}
|
|
disabled={!this.state.isEditMode}
|
|
>
|
|
{type.label}
|
|
</Radio>
|
|
))}
|
|
</div>
|
|
<Divider />
|
|
<Fieldset item={datasource} onChange={this.onDatasourceChange} compact>
|
|
{this.state.datasourceType === DATASOURCE_TYPES.virtual.key && (
|
|
<div>
|
|
{this.state.isSqla && (
|
|
<>
|
|
<Col xs={24} md={12}>
|
|
<Field
|
|
fieldKey="databaseSelector"
|
|
label={t('Virtual')}
|
|
control={
|
|
<div css={{ marginTop: 8 }}>
|
|
<DatabaseSelector
|
|
db={datasource?.database}
|
|
catalog={datasource.catalog}
|
|
schema={datasource.schema}
|
|
onCatalogChange={catalog =>
|
|
this.state.isEditMode &&
|
|
this.onDatasourcePropChange('catalog', catalog)
|
|
}
|
|
onSchemaChange={schema =>
|
|
this.state.isEditMode &&
|
|
this.onDatasourcePropChange('schema', schema)
|
|
}
|
|
onDbChange={database =>
|
|
this.state.isEditMode &&
|
|
this.onDatasourcePropChange('database', database)
|
|
}
|
|
formMode={false}
|
|
handleError={this.props.addDangerToast}
|
|
readOnly={!this.state.isEditMode}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
<div css={{ width: 'calc(100% - 34px)', marginTop: -16 }}>
|
|
<Field
|
|
fieldKey="table_name"
|
|
label={t('Name')}
|
|
control={
|
|
<TextControl
|
|
controlId="table_name"
|
|
onChange={table => {
|
|
this.onDatasourcePropChange('table_name', table);
|
|
}}
|
|
placeholder={t('Dataset name')}
|
|
disabled={!this.state.isEditMode}
|
|
/>
|
|
}
|
|
/>
|
|
</div>
|
|
</Col>
|
|
<Field
|
|
fieldKey="sql"
|
|
label={t('SQL')}
|
|
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.' +
|
|
'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()}
|
|
<TextAreaControl
|
|
hotkeys={[
|
|
{
|
|
name: 'formatQuery',
|
|
key: 'ctrl+shift+f',
|
|
descr: t('Format SQL query'),
|
|
func: () => {
|
|
this.onQueryFormat();
|
|
},
|
|
},
|
|
]}
|
|
language="sql"
|
|
offerEditInModal={false}
|
|
minLines={10}
|
|
maxLines={Infinity}
|
|
readOnly={!this.state.isEditMode}
|
|
resize="both"
|
|
/>
|
|
</>
|
|
) : (
|
|
<TextAreaControl
|
|
css={theme => css`
|
|
margin-top: ${theme.sizeUnit * 3}px;
|
|
`}
|
|
hotkeys={[
|
|
{
|
|
name: 'formatQuery',
|
|
key: 'ctrl+shift+f',
|
|
descr: t('Format SQL query'),
|
|
func: () => {
|
|
this.onQueryFormat();
|
|
},
|
|
},
|
|
]}
|
|
language="sql"
|
|
offerEditInModal={false}
|
|
minLines={10}
|
|
maxLines={Infinity}
|
|
readOnly={!this.state.isEditMode}
|
|
resize="both"
|
|
/>
|
|
)
|
|
}
|
|
additionalControl={
|
|
<div
|
|
css={css`
|
|
position: absolute;
|
|
right: 0;
|
|
top: 0;
|
|
z-index: 2;
|
|
display: flex;
|
|
`}
|
|
>
|
|
<Button
|
|
disabled={this.props.database?.isLoading}
|
|
tooltip={t('Open SQL Lab in a new tab')}
|
|
buttonStyle="secondary"
|
|
onClick={() => {
|
|
this.openOnSqlLab();
|
|
}}
|
|
icon={<Icons.ExportOutlined iconSize="s" />}
|
|
/>
|
|
<Button
|
|
disabled={this.props.database?.isLoading}
|
|
tooltip={t('Run query')}
|
|
buttonStyle="primary"
|
|
onClick={() => {
|
|
this.onQueryRun();
|
|
}}
|
|
icon={<Icons.CaretRightFilled iconSize="s" />}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
{this.props.database?.queryResult && (
|
|
<>
|
|
<div
|
|
css={theme => css`
|
|
margin-bottom: ${theme.sizeUnit}px;
|
|
`}
|
|
>
|
|
<span
|
|
css={theme => css`
|
|
color: ${theme.colorText};
|
|
font-size: ${theme.fontSizeSM}px;
|
|
`}
|
|
>
|
|
{t(
|
|
'In this view you can preview the first 25 rows. ',
|
|
)}
|
|
</span>
|
|
{this.renderOpenInSqlLabLink()}
|
|
<span
|
|
css={theme => css`
|
|
color: ${theme.colorText};
|
|
font-size: ${theme.fontSizeSM}px;
|
|
`}
|
|
>
|
|
{t(' to see details.')}
|
|
</span>
|
|
</div>
|
|
<ResultTable
|
|
data={this.props.database?.queryResult.data}
|
|
queryId={this.props.database?.queryResult.query.id}
|
|
orderedColumnKeys={this.props.database?.queryResult.columns.map(
|
|
col => col.column_name,
|
|
)}
|
|
expandedColumns={
|
|
this.props.database?.queryResult.expandedColumns
|
|
}
|
|
height={300}
|
|
allowHTML
|
|
/>
|
|
</>
|
|
)}
|
|
{this.props.database?.error && this.renderSqlErrorMessage()}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{this.state.datasourceType === DATASOURCE_TYPES.physical.key && (
|
|
<Col xs={24} md={12}>
|
|
{this.state.isSqla && (
|
|
<Field
|
|
fieldKey="tableSelector"
|
|
label={t('Physical')}
|
|
control={
|
|
<div css={{ marginTop: 8 }}>
|
|
<TableSelector
|
|
clearable={false}
|
|
database={{
|
|
...datasource.database,
|
|
database_name:
|
|
datasource.database?.database_name ||
|
|
datasource.database?.name,
|
|
}}
|
|
dbId={datasource.database?.id}
|
|
handleError={this.props.addDangerToast}
|
|
catalog={datasource.catalog}
|
|
schema={datasource.schema}
|
|
sqlLabMode={false}
|
|
tableValue={datasource.table_name}
|
|
onCatalogChange={
|
|
this.state.isEditMode
|
|
? catalog =>
|
|
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}
|
|
/>
|
|
</div>
|
|
}
|
|
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.',
|
|
)}
|
|
/>
|
|
)}
|
|
</Col>
|
|
)}
|
|
</Fieldset>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
renderErrors() {
|
|
if (this.state.errors.length > 0) {
|
|
return (
|
|
<Alert
|
|
css={theme => ({ marginBottom: theme.sizeUnit * 4 })}
|
|
type="error"
|
|
message={
|
|
<>
|
|
{this.state.errors.map(err => (
|
|
<div key={err}>{err}</div>
|
|
))}
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
renderMetricCollection() {
|
|
const { datasource } = this.state;
|
|
const { metrics } = datasource;
|
|
const sortedMetrics = metrics?.length ? this.sortMetrics(metrics) : [];
|
|
return (
|
|
<CollectionTable
|
|
tableColumns={['metric_name', 'verbose_name', 'expression']}
|
|
sortColumns={['metric_name', 'verbose_name', 'expression']}
|
|
columnLabels={{
|
|
metric_name: t('Metric Key'),
|
|
verbose_name: t('Label'),
|
|
expression: t('SQL expression'),
|
|
}}
|
|
columnLabelTooltips={{
|
|
metric_name: t(
|
|
'This field is used as a unique identifier to attach ' +
|
|
'the metric to charts. It is also used as the alias in the ' +
|
|
'SQL query.',
|
|
),
|
|
}}
|
|
expandFieldset={
|
|
<FormContainer>
|
|
<Fieldset compact>
|
|
<Field
|
|
fieldKey="description"
|
|
label={t('Description')}
|
|
control={
|
|
<TextControl
|
|
controlId="description"
|
|
placeholder={t('Description')}
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
fieldKey="d3format"
|
|
label={t('D3 format')}
|
|
control={
|
|
<TextControl controlId="d3format" placeholder="%y/%m/%d" />
|
|
}
|
|
/>
|
|
<Field
|
|
fieldKey="currency"
|
|
label={t('Metric currency')}
|
|
control={
|
|
<CurrencyControl
|
|
currencySelectOverrideProps={{
|
|
placeholder: t('Select or type currency symbol'),
|
|
}}
|
|
symbolSelectAdditionalStyles={css`
|
|
max-width: 30%;
|
|
`}
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
label={t('Certified by')}
|
|
fieldKey="certified_by"
|
|
description={t(
|
|
'Person or group that has certified this metric',
|
|
)}
|
|
control={
|
|
<TextControl
|
|
controlId="certified_by"
|
|
placeholder={t('Certified by')}
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
label={t('Certification details')}
|
|
fieldKey="certification_details"
|
|
description={t('Details of the certification')}
|
|
control={
|
|
<TextControl
|
|
controlId="certification_details"
|
|
placeholder={t('Certification details')}
|
|
/>
|
|
}
|
|
/>
|
|
<Field
|
|
label={t('Warning')}
|
|
fieldKey="warning_markdown"
|
|
description={t('Optional warning about use of this metric')}
|
|
control={
|
|
<TextAreaControl
|
|
controlId="warning_markdown"
|
|
language="markdown"
|
|
offerEditInModal={false}
|
|
resize="vertical"
|
|
/>
|
|
}
|
|
/>
|
|
</Fieldset>
|
|
</FormContainer>
|
|
}
|
|
collection={sortedMetrics}
|
|
allowAddItem
|
|
onChange={this.onDatasourcePropChange.bind(this, 'metrics')}
|
|
itemGenerator={() => ({
|
|
metric_name: t('<new metric>'),
|
|
verbose_name: '',
|
|
expression: '',
|
|
})}
|
|
itemCellProps={{
|
|
expression: () => ({
|
|
width: '240px',
|
|
}),
|
|
}}
|
|
itemRenderers={{
|
|
metric_name: (v, onChange, _, record) => (
|
|
<FlexRowContainer>
|
|
{record.is_certified && (
|
|
<CertifiedBadge
|
|
certifiedBy={record.certified_by}
|
|
details={record.certification_details}
|
|
/>
|
|
)}
|
|
{record.warning_markdown && (
|
|
<WarningIconWithTooltip
|
|
warningMarkdown={record.warning_markdown}
|
|
/>
|
|
)}
|
|
<EditableTitle
|
|
canEdit
|
|
title={v}
|
|
onSaveTitle={onChange}
|
|
maxWidth={300}
|
|
/>
|
|
</FlexRowContainer>
|
|
),
|
|
verbose_name: (v, onChange) => (
|
|
<TextControl canEdit value={v} onChange={onChange} />
|
|
),
|
|
expression: (v, onChange) => (
|
|
<TextAreaControl
|
|
canEdit
|
|
initialValue={v}
|
|
onChange={onChange}
|
|
extraClasses={['datasource-sql-expression']}
|
|
language="sql"
|
|
offerEditInModal={false}
|
|
minLines={5}
|
|
textAreaStyles={{ minWidth: '200px', maxWidth: '450px' }}
|
|
resize="both"
|
|
/>
|
|
),
|
|
description: (v, onChange, label) => (
|
|
<StackedField
|
|
label={label}
|
|
formElement={<TextControl value={v} onChange={onChange} />}
|
|
/>
|
|
),
|
|
d3format: (v, onChange, label) => (
|
|
<StackedField
|
|
label={label}
|
|
formElement={<TextControl value={v} onChange={onChange} />}
|
|
/>
|
|
),
|
|
}}
|
|
allowDeletes
|
|
stickyHeader
|
|
/>
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const { datasource, activeTabKey } = this.state;
|
|
const { metrics } = datasource;
|
|
const sortedMetrics = metrics?.length ? this.sortMetrics(metrics) : [];
|
|
const { theme } = this.props;
|
|
|
|
return (
|
|
<DatasourceContainer data-test="datasource-editor">
|
|
{this.renderErrors()}
|
|
<Alert
|
|
css={theme => ({ marginBottom: theme.sizeUnit * 4 })}
|
|
type="warning"
|
|
message={
|
|
<>
|
|
{' '}
|
|
<strong>{t('Be careful.')} </strong>
|
|
{t(
|
|
'Changing these settings will affect all charts using this dataset, including charts owned by other people.',
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
<StyledTableTabs
|
|
id="table-tabs"
|
|
data-test="edit-dataset-tabs"
|
|
onChange={this.handleTabSelect}
|
|
defaultActiveKey={activeTabKey}
|
|
items={[
|
|
{
|
|
key: TABS_KEYS.SOURCE,
|
|
label: t('Source'),
|
|
children: this.renderSourceFieldset(theme),
|
|
},
|
|
{
|
|
key: TABS_KEYS.METRICS,
|
|
label: (
|
|
<CollectionTabTitle
|
|
collection={sortedMetrics}
|
|
title={t('Metrics')}
|
|
/>
|
|
),
|
|
children: this.renderMetricCollection(),
|
|
},
|
|
{
|
|
key: TABS_KEYS.COLUMNS,
|
|
label: (
|
|
<CollectionTabTitle
|
|
collection={this.state.databaseColumns}
|
|
title={t('Columns')}
|
|
/>
|
|
),
|
|
children: (
|
|
<StyledColumnsTabWrapper>
|
|
<ColumnButtonWrapper>
|
|
<StyledButtonWrapper>
|
|
<Button
|
|
buttonSize="small"
|
|
buttonStyle="tertiary"
|
|
onClick={this.syncMetadata}
|
|
className="sync-from-source"
|
|
disabled={this.state.isEditMode}
|
|
>
|
|
<Icons.DatabaseOutlined iconSize="m" />
|
|
{t('Sync columns from source')}
|
|
</Button>
|
|
</StyledButtonWrapper>
|
|
</ColumnButtonWrapper>
|
|
<ColumnCollectionTable
|
|
className="columns-table"
|
|
columns={this.state.databaseColumns}
|
|
datasource={datasource}
|
|
onColumnsChange={databaseColumns =>
|
|
this.setColumns({ databaseColumns })
|
|
}
|
|
onDatasourceChange={this.onDatasourceChange}
|
|
/>
|
|
{this.state.metadataLoading && <Loading />}
|
|
</StyledColumnsTabWrapper>
|
|
),
|
|
},
|
|
{
|
|
key: TABS_KEYS.CALCULATED_COLUMNS,
|
|
label: (
|
|
<CollectionTabTitle
|
|
collection={this.state.calculatedColumns}
|
|
title={t('Calculated columns')}
|
|
/>
|
|
),
|
|
children: (
|
|
<StyledColumnsTabWrapper>
|
|
<ColumnCollectionTable
|
|
columns={this.state.calculatedColumns}
|
|
onColumnsChange={calculatedColumns =>
|
|
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('<new column>'),
|
|
filterable: true,
|
|
groupby: true,
|
|
expression: t('<enter SQL expression here>'),
|
|
expanded: true,
|
|
})}
|
|
/>
|
|
</StyledColumnsTabWrapper>
|
|
),
|
|
},
|
|
{
|
|
key: TABS_KEYS.SETTINGS,
|
|
label: t('Settings'),
|
|
children: (
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={12}>
|
|
<FormContainer>
|
|
{this.renderSettingsFieldset()}
|
|
</FormContainer>
|
|
</Col>
|
|
<Col xs={24} md={12}>
|
|
<FormContainer>
|
|
{this.renderAdvancedFieldset()}
|
|
</FormContainer>
|
|
</Col>
|
|
</Row>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</DatasourceContainer>
|
|
);
|
|
}
|
|
|
|
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),
|
|
);
|