Files
superset2/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx

586 lines
17 KiB
TypeScript

/* 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 React, { useState, useCallback } from 'react';
import { DatasourceType, SupersetClient, Datasource } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { css, styled, useTheme } from '@apache-superset/core/theme';
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';
// Extended Datasource interface with all properties used in this component
interface ExtendedDatasource extends Datasource {
sql?: string;
select_star?: string;
owners?: Array<{
id: number;
first_name: string;
last_name: string;
value?: number;
}>;
extra?: string;
health_check_message?: string;
database?: {
id: number;
database_name: string;
backend?: string;
};
}
interface User {
userId?: number;
username?: string;
roles?: Record<string, unknown[]>;
}
interface DatasourceControlActions {
changeDatasource: (datasource: ExtendedDatasource) => void;
setControlValue: (name: string, value: unknown) => void;
}
interface FormData {
granularity_sqla?: string;
[key: string]: unknown;
}
interface DatasourceControlProps {
actions: DatasourceControlActions;
onChange?: () => void;
value?: string | null;
datasource: ExtendedDatasource;
form_data?: FormData;
isEditable?: boolean;
onDatasourceSave?: ((datasource: ExtendedDatasource) => void) | null;
user: User;
// ControlHeader-related props
hovered?: boolean;
type?: string;
label?: string;
default?: unknown;
description?: string | null;
validationErrors?: string[];
name?: string;
}
const getDatasetType = (datasource: ExtendedDatasource): string => {
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: Record<string, React.ReactNode> = {
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: string | undefined,
tooltip: string,
) =>
displayString?.length && 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: ExtendedDatasource | null | undefined,
): string => {
if (datasource?.type === 'query') return datasource?.sql || '';
return datasource?.name || '';
};
const preventRouterLinkWhileMetaClicked = (evt: React.MouseEvent) => {
if (evt.metaKey) {
evt.preventDefault();
} else {
evt.stopPropagation();
}
};
export default function DatasourceControl({
actions,
onChange = () => {},
value = null,
datasource,
form_data,
isEditable = true,
onDatasourceSave = null,
user,
}: DatasourceControlProps) {
const theme = useTheme();
const [showEditDatasourceModal, setShowEditDatasourceModal] = useState(false);
const [showChangeDatasourceModal, setShowChangeDatasourceModal] =
useState(false);
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
const handleDatasourceSave = useCallback(
(savedDatasource: Datasource) => {
// Cast to ExtendedDatasource for the component's internal use
actions.changeDatasource(savedDatasource as ExtendedDatasource);
// Cast datasource for getTemporalColumns which expects Dataset | QueryResponse
const { temporalColumns, defaultTemporalColumn } = getTemporalColumns(
savedDatasource as Parameters<typeof getTemporalColumns>[0],
);
const { columns } = savedDatasource;
// the granularity_sqla might not be a temporal column anymore
const timeCol = form_data?.granularity_sqla;
const isGranularitySqlaTemporal = columns.find(
({ column_name }) => column_name === timeCol,
)?.is_dttm;
// the main_dttm_col might not be a temporal column anymore
const isDefaultTemporal = columns.find(
({ column_name }) => column_name === defaultTemporalColumn,
)?.is_dttm;
// if granularity_sqla is empty or it is not a temporal column anymore
// let's update the control value
if (savedDatasource.type === 'table' && !isGranularitySqlaTemporal) {
const temporalColumn = isDefaultTemporal
? defaultTemporalColumn
: temporalColumns?.[0];
actions.setControlValue('granularity_sqla', temporalColumn || null);
}
if (onDatasourceSave) {
onDatasourceSave(savedDatasource);
}
},
[actions, form_data?.granularity_sqla, onDatasourceSave],
);
const toggleChangeDatasourceModal = useCallback(() => {
setShowChangeDatasourceModal(prev => !prev);
}, []);
const toggleEditDatasourceModal = useCallback(() => {
setShowEditDatasourceModal(prev => !prev);
}, []);
const toggleSaveDatasetModal = useCallback(() => {
setShowSaveDatasetModal(prev => !prev);
}, []);
const handleMenuItemClick = useCallback(
({ key }: { key: string }) => {
switch (key) {
case CHANGE_DATASET:
toggleChangeDatasourceModal();
break;
case EDIT_DATASET:
toggleEditDatasourceModal();
break;
case VIEW_IN_SQL_LAB:
{
const payload = {
datasourceKey: `${datasource.id}__${datasource.type}`,
sql: datasource.sql,
};
SupersetClient.postForm('/sqllab/', {
form_data: safeStringify(payload),
});
}
break;
case SAVE_AS_DATASET:
toggleSaveDatasetModal();
break;
default:
break;
}
},
[
datasource,
toggleChangeDatasourceModal,
toggleEditDatasourceModal,
toggleSaveDatasetModal,
],
);
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 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 (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={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 || ''}
datasource={`${datasource.id}__${datasource.type}`}
/>
}
modalFooter={
<ViewQueryModalFooter
changeDatasource={toggleSaveDatasetModal}
datasource={{
id: String(datasource.id),
sql: datasource.sql || '',
type: datasource.type,
}}
/>
}
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: <span>{t('Save as dataset')}</span>,
});
const queryDatasourceMenu = (
<Menu onClick={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
type="warning"
message={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"
message={t('Missing dataset')}
descriptionPre={false}
descriptionDetailsCollapsed={false}
descriptionDetails={
<>
<p>
{t(
'The dataset linked to this chart may have been deleted.',
)}
</p>
<p>
<Button
buttonStyle="primary"
onClick={() =>
handleMenuItemClick({ key: CHANGE_DATASET })
}
>
{t('Swap dataset')}
</Button>
</p>
</>
}
/>
)}
</div>
)}
{showEditDatasourceModal && (
<DatasourceModal
datasource={datasource}
show={showEditDatasourceModal}
onDatasourceSave={handleDatasourceSave}
onHide={toggleEditDatasourceModal}
/>
)}
{showChangeDatasourceModal && (
<ChangeDatasourceModal
onDatasourceSave={handleDatasourceSave}
onHide={toggleChangeDatasourceModal}
show={showChangeDatasourceModal}
onChange={onChange}
/>
)}
{showSaveDatasetModal && (
<SaveDatasetModal
visible={showSaveDatasetModal}
onHide={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={form_data}
/>
)}
</Styles>
);
}