/** * 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 = ({ 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([]); const [isSaving, setIsSaving] = useState(false); const [isEditing, setIsEditing] = useState(false); const [modal, contextHolder] = Modal.useModal(); const [confirmModalOpen, setConfirmModalOpen] = useState(false); const buildPayload = (datasource: Record) => { const payload: Record = { 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) => 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: ( ), }); } }; const onDatasourceChange = (data: DatasetObject, err: Array) => { setCurrentDatasource({ ...data, metrics: data?.metrics.map((metric: DatasetObject['metrics'][0]) => ({ ...metric, is_certified: metric?.certified_by || metric?.certification_details, })), }); setErrors(err); }; const getSaveDialog = useCallback( () => (
({ 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 && (
({ marginBottom: theme.marginMD, })} > ({ 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.`)} /> { setSyncColumns(prev => !prev); }} /> ({ marginLeft: theme.marginXS, })} > {t('Automatically sync columns')}
)} {t('Are you sure you want to save and apply changes?')}
), [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 ( {t('Edit Dataset ')} {currentDatasource.table_name} } maskClosable={!isEditing} footer={ <> } responsive resizable resizableConfig={{ defaultSize: { width: 'auto', height: `${MODAL_HEIGHT_VH}vh` }, maxHeight: `${MODAL_HEIGHT_VH}vh`, }} draggable > {contextHolder} {getSaveDialog()} ); }; export default withToasts(DatasourceModal);