| /* eslint-disable camelcase */ |
| /** |
| * 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 { PureComponent } from 'react'; |
| import PropTypes from 'prop-types'; |
| import { |
| css, |
| DatasourceType, |
| SupersetClient, |
| styled, |
| t, |
| withTheme, |
| } from '@superset-ui/core'; |
| import { getTemporalColumns } from '@superset-ui/chart-controls'; |
| import { getUrlParam } from 'src/utils/urlUtils'; |
| import { |
| Dropdown, |
| Tooltip, |
| Button, |
| ModalTrigger, |
| } from '@superset-ui/core/components'; |
| import { |
| ChangeDatasourceModal, |
| DatasourceModal, |
| ErrorAlert, |
| } from 'src/components'; |
| import { Menu } from '@superset-ui/core/components/Menu'; |
| import { Icons } from '@superset-ui/core/components/Icons'; |
| import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip'; |
| import { URL_PARAMS } from 'src/constants'; |
| import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils'; |
| import { |
| userHasPermission, |
| isUserAdmin, |
| } from 'src/dashboard/util/permissionUtils'; |
| import { ErrorMessageWithStackTrace } from 'src/components/ErrorMessage/ErrorMessageWithStackTrace'; |
| import ViewQueryModalFooter from 'src/explore/components/controls/ViewQueryModalFooter'; |
| import ViewQuery from 'src/explore/components/controls/ViewQuery'; |
| import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal'; |
| import { safeStringify } from 'src/utils/safeStringify'; |
| import { Link } from 'react-router-dom'; |
| |
| const propTypes = { |
| actions: PropTypes.object.isRequired, |
| onChange: PropTypes.func, |
| value: PropTypes.string, |
| datasource: PropTypes.object.isRequired, |
| form_data: PropTypes.object.isRequired, |
| isEditable: PropTypes.bool, |
| onDatasourceSave: PropTypes.func, |
| }; |
| |
| const defaultProps = { |
| onChange: () => {}, |
| onDatasourceSave: null, |
| value: null, |
| isEditable: true, |
| }; |
| |
| const getDatasetType = datasource => { |
| if (datasource.type === 'query') { |
| return 'query'; |
| } |
| if (datasource.type === 'table' && datasource.sql) { |
| return 'virtual_dataset'; |
| } |
| return 'physical_dataset'; |
| }; |
| |
| const Styles = styled.div` |
| .data-container { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| border-bottom: 1px solid ${({ theme }) => theme.colorSplit}; |
| padding: ${({ theme }) => 4 * theme.sizeUnit}px; |
| padding-right: ${({ theme }) => 2 * theme.sizeUnit}px; |
| } |
| .error-alert { |
| margin: ${({ theme }) => 2 * theme.sizeUnit}px; |
| min-height: 150px; |
| } |
| .ant-dropdown-trigger { |
| margin-left: ${({ theme }) => 2 * theme.sizeUnit}px; |
| } |
| .btn-group .open .dropdown-toggle { |
| box-shadow: none; |
| &.button-default { |
| background: none; |
| } |
| } |
| i.angle { |
| color: ${({ theme }) => theme.colorPrimary}; |
| } |
| svg.datasource-modal-trigger { |
| color: ${({ theme }) => theme.colorPrimary}; |
| cursor: pointer; |
| } |
| .title-select { |
| flex: 1 1 100%; |
| display: inline-block; |
| padding: ${({ theme }) => theme.sizeUnit * 2}px 0px; |
| border-radius: ${({ theme }) => theme.borderRadius}px; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| overflow: hidden; |
| } |
| .datasource-svg { |
| margin-right: ${({ theme }) => 2 * theme.sizeUnit}px; |
| flex: none; |
| } |
| span[aria-label='dataset-physical'] { |
| color: ${({ theme }) => theme.colorIcon}; |
| } |
| span[aria-label='more'] { |
| color: ${({ theme }) => theme.colorPrimary}; |
| } |
| `; |
| |
| const CHANGE_DATASET = 'change_dataset'; |
| const VIEW_IN_SQL_LAB = 'view_in_sql_lab'; |
| const EDIT_DATASET = 'edit_dataset'; |
| const QUERY_PREVIEW = 'query_preview'; |
| const SAVE_AS_DATASET = 'save_as_dataset'; |
| |
| // If the string is longer than this value's number characters we add |
| // a tooltip for user can see the full name by hovering over the visually truncated string in UI |
| const VISIBLE_TITLE_LENGTH = 25; |
| |
| // Assign icon for each DatasourceType. If no icon assignment is found in the lookup, no icon will render |
| export const datasourceIconLookup = { |
| query: <Icons.ConsoleSqlOutlined className="datasource-svg" />, |
| physical_dataset: <Icons.TableOutlined className="datasource-svg" />, |
| virtual_dataset: <Icons.ConsoleSqlOutlined className="datasource-svg" />, |
| }; |
| |
| // Render title for datasource with tooltip only if text is longer than VISIBLE_TITLE_LENGTH |
| export const renderDatasourceTitle = (displayString, tooltip) => |
| displayString?.length > VISIBLE_TITLE_LENGTH ? ( |
| // Add a tooltip only for long names that will be visually truncated |
| <Tooltip title={tooltip}> |
| <span className="title-select">{displayString}</span> |
| </Tooltip> |
| ) : ( |
| <span title={tooltip} className="title-select"> |
| {displayString} |
| </span> |
| ); |
| |
| // Different data source types use different attributes for the display title |
| export const getDatasourceTitle = datasource => { |
| if (datasource?.type === 'query') return datasource?.sql; |
| return datasource?.name || ''; |
| }; |
| |
| const preventRouterLinkWhileMetaClicked = evt => { |
| if (evt.metaKey) { |
| evt.preventDefault(); |
| } else { |
| evt.stopPropagation(); |
| } |
| }; |
| |
| class DatasourceControl extends PureComponent { |
| constructor(props) { |
| super(props); |
| this.state = { |
| showEditDatasourceModal: false, |
| showChangeDatasourceModal: false, |
| showSaveDatasetModal: false, |
| }; |
| } |
| |
| onDatasourceSave = datasource => { |
| this.props.actions.changeDatasource(datasource); |
| const { temporalColumns, defaultTemporalColumn } = |
| getTemporalColumns(datasource); |
| const { columns } = datasource; |
| // the current granularity_sqla might not be a temporal column anymore |
| const timeCol = this.props.form_data?.granularity_sqla; |
| const isGranularitySqlaTemporal = columns.find( |
| ({ column_name }) => column_name === timeCol, |
| )?.is_dttm; |
| // the current main_dttm_col might not be a temporal column anymore |
| const isDefaultTemporal = columns.find( |
| ({ column_name }) => column_name === defaultTemporalColumn, |
| )?.is_dttm; |
| |
| // if the current granularity_sqla is empty or it is not a temporal column anymore |
| // let's update the control value |
| if (datasource.type === 'table' && !isGranularitySqlaTemporal) { |
| const temporalColumn = isDefaultTemporal |
| ? defaultTemporalColumn |
| : temporalColumns?.[0]; |
| this.props.actions.setControlValue( |
| 'granularity_sqla', |
| temporalColumn || null, |
| ); |
| } |
| |
| if (this.props.onDatasourceSave) { |
| this.props.onDatasourceSave(datasource); |
| } |
| }; |
| |
| toggleShowDatasource = () => { |
| this.setState(({ showDatasource }) => ({ |
| showDatasource: !showDatasource, |
| })); |
| }; |
| |
| toggleChangeDatasourceModal = () => { |
| this.setState(({ showChangeDatasourceModal }) => ({ |
| showChangeDatasourceModal: !showChangeDatasourceModal, |
| })); |
| }; |
| |
| toggleEditDatasourceModal = () => { |
| this.setState(({ showEditDatasourceModal }) => ({ |
| showEditDatasourceModal: !showEditDatasourceModal, |
| })); |
| }; |
| |
| toggleSaveDatasetModal = () => { |
| this.setState(({ showSaveDatasetModal }) => ({ |
| showSaveDatasetModal: !showSaveDatasetModal, |
| })); |
| }; |
| |
| handleMenuItemClick = ({ key }) => { |
| switch (key) { |
| case CHANGE_DATASET: |
| this.toggleChangeDatasourceModal(); |
| break; |
| |
| case EDIT_DATASET: |
| this.toggleEditDatasourceModal(); |
| break; |
| |
| case VIEW_IN_SQL_LAB: |
| { |
| const { datasource } = this.props; |
| const payload = { |
| datasourceKey: `${datasource.id}__${datasource.type}`, |
| sql: datasource.sql, |
| }; |
| SupersetClient.postForm('/sqllab/', { |
| form_data: safeStringify(payload), |
| }); |
| } |
| break; |
| |
| case SAVE_AS_DATASET: |
| this.toggleSaveDatasetModal(); |
| break; |
| |
| default: |
| break; |
| } |
| }; |
| |
| render() { |
| const { |
| showChangeDatasourceModal, |
| showEditDatasourceModal, |
| showSaveDatasetModal, |
| } = this.state; |
| const { datasource, onChange, theme } = this.props; |
| let extra; |
| if (datasource?.extra) { |
| if (typeof datasource.extra === 'string') { |
| try { |
| extra = JSON.parse(datasource.extra); |
| } catch {} // eslint-disable-line no-empty |
| } else { |
| extra = datasource.extra; // eslint-disable-line prefer-destructuring |
| } |
| } |
| const isMissingDatasource = !datasource?.id || Boolean(extra?.error); |
| let isMissingParams = false; |
| if (isMissingDatasource) { |
| const datasourceId = getUrlParam(URL_PARAMS.datasourceId); |
| const sliceId = getUrlParam(URL_PARAMS.sliceId); |
| |
| if (!datasourceId && !sliceId) { |
| isMissingParams = true; |
| } |
| } |
| |
| const { user } = this.props; |
| const allowEdit = |
| datasource.owners?.map(o => o.id || o.value).includes(user.userId) || |
| isUserAdmin(user); |
| |
| const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access'); |
| |
| const editText = t('Edit dataset'); |
| const requestedQuery = { |
| datasourceKey: `${datasource.id}__${datasource.type}`, |
| sql: datasource.sql, |
| }; |
| |
| const defaultDatasourceMenuItems = []; |
| if (this.props.isEditable && !isMissingDatasource) { |
| defaultDatasourceMenuItems.push({ |
| key: EDIT_DATASET, |
| label: !allowEdit ? ( |
| <Tooltip |
| title={t( |
| 'You must be a dataset owner in order to edit. Please reach out to a dataset owner to request modifications or edit access.', |
| )} |
| > |
| {editText} |
| </Tooltip> |
| ) : ( |
| editText |
| ), |
| disabled: !allowEdit, |
| ...{ 'data-test': 'edit-dataset' }, |
| }); |
| } |
| |
| defaultDatasourceMenuItems.push({ |
| key: CHANGE_DATASET, |
| label: t('Swap dataset'), |
| }); |
| |
| if (!isMissingDatasource && canAccessSqlLab) { |
| defaultDatasourceMenuItems.push({ |
| key: VIEW_IN_SQL_LAB, |
| label: ( |
| <Link |
| to={{ |
| pathname: '/sqllab', |
| state: { requestedQuery }, |
| }} |
| onClick={preventRouterLinkWhileMetaClicked} |
| > |
| {t('View in SQL Lab')} |
| </Link> |
| ), |
| }); |
| } |
| |
| const defaultDatasourceMenu = ( |
| <Menu |
| onClick={this.handleMenuItemClick} |
| items={defaultDatasourceMenuItems} |
| /> |
| ); |
| |
| const queryDatasourceMenuItems = [ |
| { |
| key: QUERY_PREVIEW, |
| label: ( |
| <ModalTrigger |
| triggerNode={ |
| <div data-test="view-query-menu-item">{t('Query preview')}</div> |
| } |
| modalTitle={t('Query preview')} |
| modalBody={ |
| <ViewQuery |
| sql={datasource?.sql || datasource?.select_star || ''} |
| /> |
| } |
| modalFooter={ |
| <ViewQueryModalFooter |
| changeDatasource={this.toggleSaveDatasetModal} |
| datasource={datasource} |
| /> |
| } |
| draggable={false} |
| resizable={false} |
| responsive |
| /> |
| ), |
| }, |
| ]; |
| |
| if (canAccessSqlLab) { |
| queryDatasourceMenuItems.push({ |
| key: VIEW_IN_SQL_LAB, |
| label: ( |
| <Link |
| to={{ |
| pathname: '/sqllab', |
| state: { requestedQuery }, |
| }} |
| onClick={preventRouterLinkWhileMetaClicked} |
| > |
| {t('View in SQL Lab')} |
| </Link> |
| ), |
| }); |
| } |
| |
| queryDatasourceMenuItems.push({ |
| key: SAVE_AS_DATASET, |
| label: t('Save as dataset'), |
| }); |
| |
| const queryDatasourceMenu = ( |
| <Menu |
| onClick={this.handleMenuItemClick} |
| items={queryDatasourceMenuItems} |
| /> |
| ); |
| |
| const { health_check_message: healthCheckMessage } = datasource; |
| |
| const titleText = |
| isMissingDatasource && !datasource.name |
| ? t('Missing dataset') |
| : getDatasourceTitle(datasource); |
| |
| const tooltip = titleText; |
| |
| return ( |
| <Styles data-test="datasource-control" className="DatasourceControl"> |
| <div className="data-container"> |
| {datasourceIconLookup[getDatasetType(datasource)]} |
| {renderDatasourceTitle(titleText, tooltip)} |
| {healthCheckMessage && ( |
| <Tooltip title={healthCheckMessage}> |
| <Icons.WarningOutlined |
| css={css` |
| margin-left: ${theme.sizeUnit * 2}px; |
| `} |
| iconColor={theme.colorWarning} |
| /> |
| </Tooltip> |
| )} |
| {extra?.warning_markdown && ( |
| <WarningIconWithTooltip warningMarkdown={extra.warning_markdown} /> |
| )} |
| <Dropdown |
| popupRender={() => |
| datasource.type === DatasourceType.Query |
| ? queryDatasourceMenu |
| : defaultDatasourceMenu |
| } |
| trigger={['click']} |
| data-test="datasource-menu" |
| > |
| <Icons.MoreOutlined |
| iconSize="xl" |
| iconColor={theme.colorPrimary} |
| className="datasource-modal-trigger" |
| data-test="datasource-menu-trigger" |
| /> |
| </Dropdown> |
| </div> |
| {/* missing dataset */} |
| {isMissingDatasource && isMissingParams && ( |
| <div className="error-alert"> |
| <ErrorAlert |
| level="warning" |
| errorType={t('Missing URL parameters')} |
| description={t( |
| 'The URL is missing the dataset_id or slice_id parameters.', |
| )} |
| /> |
| </div> |
| )} |
| {isMissingDatasource && !isMissingParams && ( |
| <div className="error-alert"> |
| {extra?.error ? ( |
| <ErrorMessageWithStackTrace |
| title={extra.error.statusText || extra.error.message} |
| subtitle={ |
| extra.error.statusText ? extra.error.message : undefined |
| } |
| error={extra.error} |
| source="explore" |
| /> |
| ) : ( |
| <ErrorAlert |
| type="warning" |
| errorType={t('Missing dataset')} |
| descriptionPre={false} |
| descriptionDetailsCollapsed={false} |
| descriptionDetails={ |
| <> |
| <p> |
| {t( |
| 'The dataset linked to this chart may have been deleted.', |
| )} |
| </p> |
| <p> |
| <Button |
| buttonStyle="warning" |
| onClick={() => |
| this.handleMenuItemClick({ key: CHANGE_DATASET }) |
| } |
| > |
| {t('Swap dataset')} |
| </Button> |
| </p> |
| </> |
| } |
| /> |
| )} |
| </div> |
| )} |
| {showEditDatasourceModal && ( |
| <DatasourceModal |
| datasource={datasource} |
| show={showEditDatasourceModal} |
| onDatasourceSave={this.onDatasourceSave} |
| onHide={this.toggleEditDatasourceModal} |
| /> |
| )} |
| {showChangeDatasourceModal && ( |
| <ChangeDatasourceModal |
| onDatasourceSave={this.onDatasourceSave} |
| onHide={this.toggleChangeDatasourceModal} |
| show={showChangeDatasourceModal} |
| onChange={onChange} |
| /> |
| )} |
| {showSaveDatasetModal && ( |
| <SaveDatasetModal |
| visible={showSaveDatasetModal} |
| onHide={this.toggleSaveDatasetModal} |
| buttonTextOnSave={t('Save')} |
| buttonTextOnOverwrite={t('Overwrite')} |
| modalDescription={t( |
| 'Save this query as a virtual dataset to continue exploring', |
| )} |
| datasource={getDatasourceAsSaveableDataset(datasource)} |
| openWindow={false} |
| formData={this.props.form_data} |
| /> |
| )} |
| </Styles> |
| ); |
| } |
| } |
| |
| DatasourceControl.propTypes = propTypes; |
| DatasourceControl.defaultProps = defaultProps; |
| |
| export default withTheme(DatasourceControl); |