Files
superset2/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
2026-04-08 16:19:10 -07:00

408 lines
13 KiB
TypeScript

/**
* 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 { FunctionComponent, useState, useEffect, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@apache-superset/core/translation';
import {
SupersetClient,
getClientErrorObject,
SupersetError,
isFeatureEnabled,
FeatureFlag,
} from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
import { styled, useTheme, css } from '@apache-superset/core/theme';
import {
Icons,
Button,
Checkbox,
Modal,
AsyncEsmComponent,
} from '@superset-ui/core/components';
import withToasts from 'src/components/MessageToasts/withToasts';
import { ErrorMessageWithStackTrace } from 'src/components';
import type { DatasetObject } from 'src/features/datasets/types';
import type { DatasourceModalProps } from '../types';
const DatasourceEditor = AsyncEsmComponent(
() => import('../components/DatasourceEditor'),
);
const MODAL_HEIGHT_VH = 90;
const TOP_MARGIN_VH = (100 - MODAL_HEIGHT_VH) / 2;
const StyledDatasourceModal = styled(Modal)`
top: ${TOP_MARGIN_VH}vh;
padding-bottom: 0;
&& .ant-modal-content {
max-height: ${MODAL_HEIGHT_VH}vh;
margin-top: 0;
margin-bottom: 0;
min-height: 500px;
min-width: 500px;
}
&& .ant-modal-body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.ant-tabs-top {
margin-top: -${({ theme }) => theme.sizeUnit * 4}px;
}
`;
function buildExtraJsonObject(
item: DatasetObject['metrics'][0] | DatasetObject['columns'][0],
) {
const certification =
item?.certified_by || item?.certification_details
? {
certified_by: item?.certified_by,
details: item?.certification_details,
}
: undefined;
return JSON.stringify({
certification,
warning_markdown: item?.warning_markdown,
});
}
const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
addSuccessToast,
datasource,
onDatasourceSave,
onHide,
show,
}) => {
const theme = useTheme();
const [currentDatasource, setCurrentDatasource] = useState(datasource);
const [syncColumns, setSyncColumns] = useState(false);
const currencies = useSelector<
{
common: {
currencies: string[];
};
},
string[]
>(state => state.common?.currencies);
const [errors, setErrors] = useState<any[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [modal, contextHolder] = Modal.useModal();
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
const buildPayload = (datasource: Record<string, any>) => {
const payload: Record<string, any> = {
table_name: datasource.table_name,
database_id: datasource.database?.id,
sql: datasource.sql,
filter_select_enabled: datasource.filter_select_enabled,
fetch_values_predicate: datasource.fetch_values_predicate,
schema:
datasource.tableSelector?.schema ||
datasource.databaseSelector?.schema ||
datasource.schema,
description: datasource.description,
main_dttm_col: datasource.main_dttm_col,
currency_code_column: datasource.currency_code_column ?? null,
normalize_columns: datasource.normalize_columns,
always_filter_main_dttm: datasource.always_filter_main_dttm,
offset: datasource.offset,
default_endpoint: datasource.default_endpoint,
cache_timeout:
datasource.cache_timeout === '' ? null : datasource.cache_timeout,
is_sqllab_view: datasource.is_sqllab_view,
template_params: datasource.template_params,
extra: datasource.extra,
is_managed_externally: datasource.is_managed_externally,
external_url: datasource.external_url,
metrics: datasource?.metrics?.map(
(metric: DatasetObject['metrics'][0]) => {
const metricBody: any = {
expression: metric.expression,
description: metric.description,
metric_name: metric.metric_name,
metric_type: metric.metric_type,
d3format: metric.d3format || null,
currency: metric.currency,
verbose_name: metric.verbose_name,
warning_text: metric.warning_text,
uuid: metric.uuid,
extra: buildExtraJsonObject(metric),
};
if (!Number.isNaN(Number(metric.id))) {
metricBody.id = metric.id;
}
return metricBody;
},
),
columns: datasource?.columns?.map(
(column: DatasetObject['columns'][0]) => ({
id: typeof column.id === 'number' ? column.id : undefined,
column_name: column.column_name,
type: column.type,
advanced_data_type: column.advanced_data_type,
verbose_name: column.verbose_name,
description: column.description,
expression: column.expression,
filterable: column.filterable,
groupby: column.groupby,
is_active: column.is_active,
is_dttm: column.is_dttm,
python_date_format: column.python_date_format || null,
uuid: column.uuid,
extra: buildExtraJsonObject(column),
}),
),
owners: datasource.owners.map(
(o: Record<string, number>) => o.value || o.id,
),
};
// Add folders if DATASET_FOLDERS feature is enabled
if (isFeatureEnabled(FeatureFlag.DatasetFolders) && datasource.folders) {
payload.folders = datasource.folders;
}
// Handle catalog based on database's allow_multi_catalog setting
// If multi-catalog is disabled, don't include catalog in payload
// The backend will use the default catalog
// If multi-catalog is enabled, include the selected catalog
if (datasource.database?.allow_multi_catalog) {
payload.catalog = datasource.catalog;
}
return payload;
};
const onConfirmSave = async () => {
// Pull out extra fields into the extra object
setIsSaving(true);
try {
await SupersetClient.put({
endpoint: `/api/v1/dataset/${currentDatasource.id}?override_columns=${syncColumns}`,
jsonPayload: buildPayload(currentDatasource),
});
const { json } = await SupersetClient.get({
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
});
addSuccessToast(t('The dataset has been saved'));
// eslint-disable-next-line no-param-reassign
json.result.type = 'table';
onDatasourceSave({
...json.result,
owners: currentDatasource.owners,
});
onHide();
} catch (response) {
setIsSaving(false);
const error = await getClientErrorObject(response);
let errorResponse: SupersetError | undefined;
let errorText: string | undefined;
// sip-40 error response
if (error?.errors?.length) {
errorResponse = error.errors[0];
} else if (typeof error.error === 'string') {
// backward compatible with old error messages
errorText = error.error;
}
modal.error({
title: t('Error saving dataset'),
okButtonProps: { danger: true, className: 'btn-danger' },
content: (
<ErrorMessageWithStackTrace
error={errorResponse}
source="crud"
fallback={errorText}
/>
),
});
}
};
const onDatasourceChange = (data: DatasetObject, err: Array<any>) => {
setCurrentDatasource({
...data,
metrics: data?.metrics.map((metric: DatasetObject['metrics'][0]) => ({
...metric,
is_certified: metric?.certified_by || metric?.certification_details,
})),
});
setErrors(err);
};
const getSaveDialog = useCallback(
() => (
<div>
<Alert
css={theme => ({
marginTop: theme.marginMD,
marginBottom: theme.marginSM,
})}
type="warning"
showIcon={false}
message={t(`The dataset configuration exposed here
affects all the charts using this dataset.
Be mindful that changing settings
here may affect other charts
in undesirable ways.`)}
/>
{datasource.sql !== currentDatasource.sql && (
<div
css={theme => ({
marginBottom: theme.marginMD,
})}
>
<Alert
css={theme => ({
marginBottom: theme.marginSM,
})}
type="info"
showIcon={false}
message={t(`The dataset columns will be automatically synced
based on the changes in your SQL query. If your changes don't
impact the column definitions, you might want to skip this step.`)}
/>
<Checkbox
checked={syncColumns}
onChange={() => {
setSyncColumns(prev => !prev);
}}
/>
<span
css={theme => ({
marginLeft: theme.marginXS,
})}
>
{t('Automatically sync columns')}
</span>
</div>
)}
{t('Are you sure you want to save and apply changes?')}
</div>
),
[currentDatasource.sql, datasource.sql, syncColumns],
);
useEffect(() => {
if (datasource.sql !== currentDatasource.sql) {
setSyncColumns(true);
}
}, [datasource.sql, currentDatasource.sql]);
const onClickSave = () => {
setConfirmModalOpen(true);
};
const handleConfirmModalClose = () => {
setConfirmModalOpen(false);
};
const handleConfirmSave = async () => {
await onConfirmSave();
// Note: on success, onConfirmSave calls onHide() which closes parent modal
// On error, confirmModal stays open so user can see the error and try again or cancel
};
return (
<StyledDatasourceModal
show={show}
onHide={onHide}
title={
<span>
<Icons.EditOutlined
iconSize="l"
css={css`
margin: auto ${theme.sizeUnit * 2}px auto 0;
`}
data-test="edit-alt"
/>
{t('Edit Dataset ')}
<strong>{currentDatasource.table_name}</strong>
</span>
}
maskClosable={!isEditing}
footer={
<>
<Button
data-test="datasource-modal-cancel"
buttonSize="small"
buttonStyle="secondary"
onClick={onHide}
>
{t('Cancel')}
</Button>
<Button
buttonSize="small"
buttonStyle="primary"
data-test="datasource-modal-save"
onClick={onClickSave}
disabled={
isSaving ||
errors.length > 0 ||
currentDatasource.is_managed_externally
}
tooltip={
currentDatasource.is_managed_externally
? t(
"This dataset is managed externally, and can't be edited in Superset",
)
: errors.length > 0
? errors.join('\n')
: ''
}
>
{t('Save')}
</Button>
</>
}
responsive
resizable
resizableConfig={{
defaultSize: { width: 'auto', height: `${MODAL_HEIGHT_VH}vh` },
maxHeight: `${MODAL_HEIGHT_VH}vh`,
}}
draggable
>
<DatasourceEditor
showLoadingForImport
height={500}
datasource={currentDatasource}
onChange={onDatasourceChange}
setIsEditing={setIsEditing}
currencies={currencies}
/>
{contextHolder}
<Modal
title={t('Confirm save')}
show={confirmModalOpen}
onHide={handleConfirmModalClose}
onHandledPrimaryAction={handleConfirmSave}
primaryButtonName={t('OK')}
primaryButtonLoading={isSaving}
>
{getSaveDialog()}
</Modal>
</StyledDatasourceModal>
);
};
export default withToasts(DatasourceModal);