Skip to content

Commit

Permalink
feat: add modal to import dashboards
Browse files Browse the repository at this point in the history
  • Loading branch information
betodealmeida committed Dec 8, 2020
1 parent 33325f9 commit 89bbdcf
Show file tree
Hide file tree
Showing 9 changed files with 387 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -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 ImportDashboardModal from 'src/dashboard/components/ImportModal';
import Modal from 'src/common/components/Modal';

const mockStore = configureStore([thunk]);
const store = mockStore({});

const requiredProps = {
addDangerToast: () => {},
addSuccessToast: () => {},
onDashboardImport: () => {},
show: true,
onHide: () => {},
};

describe('ImportDashboardModal', () => {
let wrapper: ReactWrapper;

beforeEach(() => {
wrapper = mount(<ImportDashboardModal {...requiredProps} />, {
context: { store },
});
});

afterEach(() => {
jest.clearAllMocks();
});

it('renders', () => {
expect(wrapper.find(ImportDashboardModal)).toExist();
});

it('renders a Modal', () => {
expect(wrapper.find(Modal)).toExist();
});

it('renders "Import Dashboard" header', () => {
expect(wrapper.find('h4').text()).toEqual('Import Dashboard');
});

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 = 'dashboardFile';
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)], 'dashboard_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(
<ImportDashboardModal
{...requiredProps}
passwordFields={['databases/examples.yaml']}
/>,
{
context: { store },
},
);
expect(wrapperWithPasswords.find('input[type="password"]')).toExist();
});
});
190 changes: 190 additions & 0 deletions superset-frontend/src/dashboard/components/ImportModal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* 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 { DashboardObject } from 'src/views/CRUD/dashboard/types';

export interface ImportDashboardModalProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
onDashboardImport: () => void;
show: boolean;
onHide: () => void;
passwordFields?: string[];
setPasswordFields?: (passwordFields: string[]) => void;
}

const ImportDashboardModal: FunctionComponent<ImportDashboardModalProps> = ({
addDangerToast,
addSuccessToast,
onDashboardImport,
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<DashboardObject>(
'dashboard',
t('dashboard'),
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 dashboards have been imported'));
clearModal();
onDashboardImport();
}
});
};

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 dashboards. 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="dashboard"
className="dashboard-modal"
disablePrimaryButton={uploadFile === null}
onHandledPrimaryAction={onUpload}
onHide={hide}
primaryButtonName={t('Import')}
width="750px"
show={show}
title={
<h4>
<StyledIcon name="nav-dashboard" />
{t('Import Dashboard')}
</h4>
}
>
<StyledInputContainer>
<div className="control-label">
<label htmlFor="dashboardFile">
{t('File')}
<span className="required">*</span>
</label>
</div>
<input
ref={fileInputRef}
data-test="dashboard-file-input"
name="dashboardFile"
id="dashboardFile"
type="file"
accept=".yaml,.json,.yml,.zip"
onChange={changeFile}
/>
</StyledInputContainer>
{renderPasswordFields()}
</Modal>
);
};

export default ImportDashboardModal;
34 changes: 34 additions & 0 deletions superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import Icon from 'src/components/Icon';
import FaveStar from 'src/components/FaveStar';
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
import TooltipWrapper from 'src/components/TooltipWrapper';
import ImportDashboardModal from 'src/dashboard/components/ImportModal/index';

import Dashboard from 'src/dashboard/containers/Dashboard';
import DashboardCard from './DashboardCard';
Expand Down Expand Up @@ -93,6 +94,22 @@ function DashboardList(props: DashboardListProps) {
null,
);

const [importingDashboard, showImportModal] = useState<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);

function openDashboardImportModal() {
showImportModal(true);
}

function closeDashboardImportModal() {
showImportModal(false);
}

const handleDashboardImport = () => {
showImportModal(false);
refreshData();
};

const canCreate = hasPerm('can_add');
const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete');
Expand Down Expand Up @@ -439,6 +456,13 @@ function DashboardList(props: DashboardListProps) {
},
});
}
if (isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT)) {
subMenuButtons.push({
name: <Icon name="import" />,
buttonStyle: 'link',
onClick: openDashboardImportModal,
});
}
return (
<>
<SubMenu name={t('Dashboards')} buttons={subMenuButtons} />
Expand Down Expand Up @@ -502,6 +526,16 @@ function DashboardList(props: DashboardListProps) {
);
}}
</ConfirmStatusChange>
<ImportDashboardModal
show={importingDashboard}
onHide={closeDashboardImportModal}
addDangerToast={props.addDangerToast}
addSuccessToast={props.addSuccessToast}
onDashboardImport={handleDashboardImport}
passwordFields={passwordFields}
setPasswordFields={setPasswordFields}
/>
:
</>
);
}
Expand Down
26 changes: 26 additions & 0 deletions superset-frontend/src/views/CRUD/dashboard/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* 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 DashboardObject = {
dashboard_title: string;
description?: string;
css?: string;
slug?: string;
position?: string;
metadata?: string;
};
Loading

0 comments on commit 89bbdcf

Please sign in to comment.