feat: add modal to import charts (#11956)

* WIP

* Splat props
This commit is contained in:
Beto Dealmeida 2020-12-07 18:33:59 -08:00 committed by GitHub
parent fbb458fa8b
commit 33325f9fa6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 401 additions and 16 deletions

View File

@ -0,0 +1,106 @@
/**
* 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 from 'react';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import { styledMount as mount } from 'spec/helpers/theming';
import { ReactWrapper } from 'enzyme';
import ImportChartModal from 'src/chart/components/ImportModal';
import Modal from 'src/common/components/Modal';
const mockStore = configureStore([thunk]);
const store = mockStore({});
const requiredProps = {
addDangerToast: () => {},
addSuccessToast: () => {},
onChartImport: () => {},
show: true,
onHide: () => {},
};
describe('ImportChartModal', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
wrapper = mount(<ImportChartModal {...requiredProps} />, {
context: { store },
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('renders', () => {
expect(wrapper.find(ImportChartModal)).toExist();
});
it('renders a Modal', () => {
expect(wrapper.find(Modal)).toExist();
});
it('renders "Import Chart" header', () => {
expect(wrapper.find('h4').text()).toEqual('Import Chart');
});
it('renders a label and a file input field', () => {
expect(wrapper.find('input[type="file"]')).toExist();
expect(wrapper.find('label')).toExist();
});
it('should attach the label to the input field', () => {
const id = 'chartFile';
expect(wrapper.find('label').prop('htmlFor')).toBe(id);
expect(wrapper.find('input').prop('id')).toBe(id);
});
it('should render the close, import and cancel buttons', () => {
expect(wrapper.find('button')).toHaveLength(3);
});
it('should render the import button initially disabled', () => {
expect(wrapper.find('button[children="Import"]').prop('disabled')).toBe(
true,
);
});
it('should render the import button enabled when a file is selected', () => {
const file = new File([new ArrayBuffer(1)], 'chart_export.zip');
wrapper.find('input').simulate('change', { target: { files: [file] } });
expect(wrapper.find('button[children="Import"]').prop('disabled')).toBe(
false,
);
});
it('should render password fields when needed for import', () => {
const wrapperWithPasswords = mount(
<ImportChartModal
{...requiredProps}
passwordFields={['databases/examples.yaml']}
/>,
{
context: { store },
},
);
expect(wrapperWithPasswords.find('input[type="password"]')).toExist();
});
});

View File

@ -0,0 +1,186 @@
/**
* 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, { FunctionComponent, useEffect, useRef, useState } from 'react';
import { t } from '@superset-ui/core';
import Modal from 'src/common/components/Modal';
import {
StyledIcon,
StyledInputContainer,
} from 'src/views/CRUD/data/database/DatabaseModal';
import { useImportResource } from 'src/views/CRUD/hooks';
import { ChartObject } from 'src/views/CRUD/chart/types';
export interface ImportChartModalProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
onChartImport: () => void;
show: boolean;
onHide: () => void;
passwordFields?: string[];
setPasswordFields?: (passwordFields: string[]) => void;
}
const ImportChartModal: FunctionComponent<ImportChartModalProps> = ({
addDangerToast,
addSuccessToast,
onChartImport,
show,
onHide,
passwordFields = [],
setPasswordFields = () => {},
}) => {
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [isHidden, setIsHidden] = useState<boolean>(true);
const [passwords, setPasswords] = useState<Record<string, string>>({});
const fileInputRef = useRef<HTMLInputElement>(null);
const clearModal = () => {
setUploadFile(null);
setPasswordFields([]);
setPasswords({});
if (fileInputRef && fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleErrorMsg = (msg: string) => {
clearModal();
addDangerToast(msg);
};
const {
state: { passwordsNeeded },
importResource,
} = useImportResource<ChartObject>('chart', t('chart'), handleErrorMsg);
useEffect(() => {
setPasswordFields(passwordsNeeded);
}, [passwordsNeeded]);
// Functions
const hide = () => {
setIsHidden(true);
onHide();
};
const onUpload = () => {
if (uploadFile === null) {
return;
}
importResource(uploadFile, passwords).then(result => {
if (result) {
addSuccessToast(t('The charts have been imported'));
clearModal();
onChartImport();
}
});
};
const changeFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const { files } = event.target as HTMLInputElement;
setUploadFile((files && files[0]) || null);
};
const renderPasswordFields = () => {
if (passwordFields.length === 0) {
return null;
}
return (
<>
<h5>Database passwords</h5>
<StyledInputContainer>
<div className="helper">
{t(
'The passwords for the databases below are needed in order to ' +
'import them together with the charts. Please note that the ' +
'"Secure Extra" and "Certificate" sections of ' +
'the database configuration are not present in export files, and ' +
'should be added manually after the import if they are needed.',
)}
</div>
</StyledInputContainer>
{passwordFields.map(fileName => (
<StyledInputContainer key={`password-for-${fileName}`}>
<div className="control-label">
{fileName}
<span className="required">*</span>
</div>
<input
name={`password-${fileName}`}
autoComplete="off"
type="password"
value={passwords[fileName]}
onChange={event =>
setPasswords({ ...passwords, [fileName]: event.target.value })
}
/>
</StyledInputContainer>
))}
</>
);
};
// Show/hide
if (isHidden && show) {
setIsHidden(false);
}
return (
<Modal
name="chart"
className="chart-modal"
disablePrimaryButton={uploadFile === null}
onHandledPrimaryAction={onUpload}
onHide={hide}
primaryButtonName={t('Import')}
width="750px"
show={show}
title={
<h4>
<StyledIcon name="nav-charts" />
{t('Import Chart')}
</h4>
}
>
<StyledInputContainer>
<div className="control-label">
<label htmlFor="chartFile">
{t('File')}
<span className="required">*</span>
</label>
</div>
<input
ref={fileInputRef}
data-test="chart-file-input"
name="chartFile"
id="chartFile"
type="file"
accept=".yaml,.json,.yml,.zip"
onChange={changeFile}
/>
</StyledInputContainer>
{renderPasswordFields()}
</Modal>
);
};
export default ImportChartModal;

View File

@ -95,7 +95,7 @@ describe('ImportDatasetModal', () => {
const wrapperWithPasswords = mount(
<ImportDatasetModal
{...requiredProps}
passwordFields={['datasets/examples.yaml']}
passwordFields={['databases/examples.yaml']}
/>,
{
context: { store },

View File

@ -17,7 +17,7 @@
* under the License.
*/
import { SupersetClient, getChartMetadataRegistry, t } from '@superset-ui/core';
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import rison from 'rison';
import { uniqBy } from 'lodash';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
@ -43,6 +43,7 @@ import ListView, {
} from 'src/components/ListView';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import PropertiesModal from 'src/explore/components/PropertiesModal';
import ImportChartModal from 'src/chart/components/ImportModal/index';
import Chart from 'src/types/Chart';
import TooltipWrapper from 'src/components/TooltipWrapper';
import ChartCard from './ChartCard';
@ -96,6 +97,8 @@ interface ChartListProps {
}
function ChartList(props: ChartListProps) {
const { addDangerToast, addSuccessToast } = props;
const {
state: {
loading,
@ -108,14 +111,14 @@ function ChartList(props: ChartListProps) {
fetchData,
toggleBulkSelect,
refreshData,
} = useListViewResource<Chart>('chart', t('chart'), props.addDangerToast);
} = useListViewResource<Chart>('chart', t('chart'), addDangerToast);
const chartIds = useMemo(() => charts.map(c => c.id), [charts]);
const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus(
'chart',
chartIds,
props.addDangerToast,
addDangerToast,
);
const {
sliceCurrentlyEditing,
@ -124,6 +127,22 @@ function ChartList(props: ChartListProps) {
closeChartEditModal,
} = useChartEditModal(setCharts, charts);
const [importingChart, showImportModal] = useState<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
function openChartImportModal() {
showImportModal(true);
}
function closeChartImportModal() {
showImportModal(false);
}
const handleChartImport = () => {
showImportModal(false);
refreshData();
};
const canCreate = hasPerm('can_add');
const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete');
@ -139,10 +158,10 @@ function ChartList(props: ChartListProps) {
}).then(
({ json = {} }) => {
refreshData();
props.addSuccessToast(json.message);
addSuccessToast(json.message);
},
createErrorHandler(errMsg =>
props.addDangerToast(
addDangerToast(
t('There was an issue deleting the selected charts: %s', errMsg),
),
),
@ -246,8 +265,8 @@ function ChartList(props: ChartListProps) {
const handleDelete = () =>
handleChartDelete(
original,
props.addSuccessToast,
props.addDangerToast,
addSuccessToast,
addDangerToast,
refreshData,
);
const openEditModal = () => openChartEditModal(original);
@ -342,7 +361,7 @@ function ChartList(props: ChartListProps) {
'chart',
'owners',
createErrorHandler(errMsg =>
props.addDangerToast(
addDangerToast(
t(
'An error occurred while fetching chart owners values: %s',
errMsg,
@ -363,7 +382,7 @@ function ChartList(props: ChartListProps) {
'chart',
'created_by',
createErrorHandler(errMsg =>
props.addDangerToast(
addDangerToast(
t(
'An error occurred while fetching chart created by values: %s',
errMsg,
@ -406,7 +425,7 @@ function ChartList(props: ChartListProps) {
unfilteredLabel: 'All',
fetchSelects: createFetchDatasets(
createErrorHandler(errMsg =>
props.addDangerToast(
addDangerToast(
t(
'An error occurred while fetching chart dataset values: %s',
errMsg,
@ -452,8 +471,8 @@ function ChartList(props: ChartListProps) {
hasPerm={hasPerm}
openChartEditModal={openChartEditModal}
bulkSelectEnabled={bulkSelectEnabled}
addDangerToast={props.addDangerToast}
addSuccessToast={props.addSuccessToast}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
refreshData={refreshData}
loading={loading}
favoriteStatus={favoriteStatus[chart.id]}
@ -482,6 +501,13 @@ function ChartList(props: ChartListProps) {
},
});
}
if (isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT)) {
subMenuButtons.push({
name: <Icon name="import" />,
buttonStyle: 'link',
onClick: openChartImportModal,
});
}
return (
<>
<SubMenu name={t('Charts')} buttons={subMenuButtons} />
@ -541,6 +567,16 @@ function ChartList(props: ChartListProps) {
);
}}
</ConfirmStatusChange>
<ImportChartModal
show={importingChart}
onHide={closeChartImportModal}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
onChartImport={handleChartImport}
passwordFields={passwordFields}
setPasswordFields={setPasswordFields}
/>
</>
);
}

View File

@ -0,0 +1,27 @@
/**
* 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.
*/
export type ChartObject = {
slice_name?: string;
description?: string;
viz_type?: string;
params?: string;
cache_timeout?: number;
datasource_id?: number;
datasource_type?: number;
};

View File

@ -878,7 +878,13 @@ class ChartRestApi(BaseSupersetModelRestApi):
for file_name in bundle.namelist()
}
command = ImportChartsCommand(contents)
passwords = (
json.loads(request.form["passwords"])
if "passwords" in request.form
else None
)
command = ImportChartsCommand(contents, passwords=passwords)
try:
command.run()
return self.response(200, message="OK")

View File

@ -43,12 +43,14 @@ class ImportChartsCommand(BaseCommand):
# pylint: disable=unused-argument
def __init__(self, contents: Dict[str, str], *args: Any, **kwargs: Any):
self.contents = contents
self.args = args
self.kwargs = kwargs
def run(self) -> None:
# iterate over all commands until we find a version that can
# handle the contents
for version in command_versions:
command = version(self.contents)
command = version(self.contents, *self.args, **self.kwargs)
try:
command.run()
return

View File

@ -36,6 +36,7 @@ from superset.databases.commands.importers.v1.utils import import_database
from superset.databases.schemas import ImportV1DatabaseSchema
from superset.datasets.commands.importers.v1.utils import import_dataset
from superset.datasets.schemas import ImportV1DatasetSchema
from superset.models.core import Database
from superset.models.slice import Slice
schemas: Dict[str, Schema] = {
@ -52,6 +53,7 @@ class ImportChartsCommand(BaseCommand):
# pylint: disable=unused-argument
def __init__(self, contents: Dict[str, str], *args: Any, **kwargs: Any):
self.contents = contents
self.passwords: Dict[str, str] = kwargs.get("passwords") or {}
self._configs: Dict[str, Any] = {}
def _import_bundle(self, session: Session) -> None:
@ -113,6 +115,14 @@ class ImportChartsCommand(BaseCommand):
def validate(self) -> None:
exceptions: List[ValidationError] = []
# load existing databases so we can apply the password validation
db_passwords = {
str(uuid): password
for uuid, password in db.session.query(
Database.uuid, Database.password
).all()
}
# verify that the metadata file is present and valid
try:
metadata: Optional[Dict[str, str]] = load_metadata(self.contents)
@ -120,12 +130,20 @@ class ImportChartsCommand(BaseCommand):
exceptions.append(exc)
metadata = None
# validate charts, datasets, and databases
for file_name, content in self.contents.items():
prefix = file_name.split("/")[0]
schema = schemas.get(f"{prefix}/")
if schema:
try:
config = load_yaml(file_name, content)
# populate passwords from the request or from existing DBs
if file_name in self.passwords:
config["password"] = self.passwords[file_name]
elif prefix == "databases" and config["uuid"] in db_passwords:
config["password"] = db_passwords[config["uuid"]]
schema.load(config)
self._configs[file_name] = config
except ValidationError as exc:

View File

@ -1118,6 +1118,8 @@ class GetFavStarIdsSchema(Schema):
class ImportV1ChartSchema(Schema):
slice_name = fields.String(required=True)
viz_type = fields.String(required=True)
params = fields.Dict()
cache_timeout = fields.Integer(allow_none=True)
uuid = fields.UUID(required=True)

View File

@ -129,7 +129,7 @@ class ImportV1ColumnSchema(Schema):
verbose_name = fields.String(allow_none=True)
is_dttm = fields.Boolean()
is_active = fields.Boolean(allow_none=True)
type = fields.String(required=True)
type = fields.String(allow_none=True)
groupby = fields.Boolean()
filterable = fields.Boolean()
expression = fields.String(allow_none=True)

View File

@ -404,6 +404,8 @@ dataset_config: Dict[str, Any] = {
}
chart_config: Dict[str, Any] = {
"slice_name": "Deck Path",
"viz_type": "deck_path",
"params": {
"color_picker": {"a": 1, "b": 135, "g": 122, "r": 0},
"datasource": "12__table",