feat: base tabbed modal for new database CRUD UI (#10668)

This commit is contained in:
Moriah Kreeger 2020-08-27 14:28:06 -07:00 committed by GitHub
parent 937b868321
commit c715cad48e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 551 additions and 9 deletions

View File

@ -0,0 +1,21 @@
<!--
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.
-->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 5C2.44772 5 2 5.44772 2 6V10C2 10.5523 2.44772 11 3 11H21C21.5523 11 22 10.5523 22 10V6C22 5.44772 21.5523 5 21 5H3ZM3 13C2.44772 13 2 13.4477 2 14V18C2 18.5523 2.44772 19 3 19H21C21.5523 19 22 18.5523 22 18V14C22 13.4477 21.5523 13 21 13H3Z" fill="#323232"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -22,6 +22,7 @@ import configureStore from 'redux-mock-store';
import { styledMount as mount } from 'spec/helpers/theming';
import DatabaseList from 'src/views/CRUD/data/database/DatabaseList';
import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal';
import SubMenu from 'src/components/Menu/SubMenu';
// store needed for withToasts(DatabaseList)
@ -38,4 +39,8 @@ describe('DatabaseList', () => {
it('renders a SubMenu', () => {
expect(wrapper.find(SubMenu)).toExist();
});
it('renders a DatabaseModal', () => {
expect(wrapper.find(DatabaseModal)).toExist();
});
});

View File

@ -0,0 +1,41 @@
/**
* 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 DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal';
import Modal from 'src/common/components/Modal';
// store needed for withToasts(DatabaseModal)
const mockStore = configureStore([thunk]);
const store = mockStore({});
describe('DatabaseModal', () => {
const wrapper = mount(<DatabaseModal />, { context: { store } });
it('renders', () => {
expect(wrapper.find(DatabaseModal)).toExist();
});
it('renders a Modal', () => {
expect(wrapper.find(Modal)).toExist();
});
});

View File

@ -0,0 +1,127 @@
/**
* 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 styled from '@superset-ui/style';
import { Modal as BaseModal } from 'src/common/components';
import { t } from '@superset-ui/translation';
import Button from 'src/views/CRUD/data/dataset/Button';
interface ModalProps {
className?: string;
children: React.ReactNode;
disablePrimaryButton?: boolean;
onHide: () => void;
onHandledPrimaryAction: () => void;
primaryButtonName: string;
primaryButtonType?: 'primary' | 'danger';
show: boolean;
title: React.ReactNode;
width?: string;
centered?: boolean;
}
const StyledModal = styled(BaseModal)`
.ant-modal-header {
background-color: ${({ theme }) => theme.colors.grayscale.light4};
border-radius: ${({ theme }) => theme.borderRadius}px
${({ theme }) => theme.borderRadius}px 0 0;
.ant-modal-title h4 {
display: flex;
margin: 0;
align-items: center;
}
}
.ant-modal-close-x {
display: flex;
align-items: center;
.close {
flex: 1 1 auto;
margin-bottom: 3px;
color: ${({ theme }) => theme.colors.secondary.dark1};
font-size: 32px;
font-weight: ${({ theme }) => theme.typography.weights.light};
}
}
.ant-modal-body {
padding: 18px;
}
.ant-modal-footer {
border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
padding: 16px;
.btn {
font-size: 12px;
text-transform: uppercase;
}
.btn + .btn {
margin-left: 8px;
}
}
`;
export default function Modal({
children,
disablePrimaryButton = false,
onHide,
onHandledPrimaryAction,
primaryButtonName,
primaryButtonType = 'primary',
show,
title,
width,
centered,
...rest
}: ModalProps) {
return (
<StyledModal
centered={!!centered}
onOk={onHandledPrimaryAction}
onCancel={onHide}
width={width || '600px'}
visible={show}
title={title}
closeIcon={
<span className="close" aria-hidden="true">
×
</span>
}
footer={[
<Button key="back" onClick={onHide}>
{t('Cancel')}
</Button>,
<Button
key="submit"
disabled={disablePrimaryButton}
onClick={onHandledPrimaryAction}
>
{primaryButtonName}
</Button>,
]}
{...rest}
>
{children}
</StyledModal>
);
}

View File

@ -0,0 +1,55 @@
/**
* 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 styled from '@superset-ui/style';
import { Tabs as BaseTabs } from 'src/common/components';
const Tabs = styled(BaseTabs)`
margin-top: -18px;
.ant-tabs-nav-list {
width: 100%;
}
.ant-tabs-tab {
flex: 1 1 auto;
width: 0;
&.ant-tabs-tab-active .ant-tabs-tab-btn {
color: inherit;
}
}
.ant-tabs-tab-btn {
flex: 1 1 auto;
font-size: ${({ theme }) => theme.typography.sizes.s}px;
text-align: center;
text-transform: uppercase;
.required {
margin-left: ${({ theme }) => theme.gridUnit / 2}px;
color: ${({ theme }) => theme.colors.error.base};
}
}
.ant-tabs-ink-bar {
background: ${({ theme }) => theme.colors.secondary.base};
}
`;
export default Tabs;

View File

@ -28,6 +28,7 @@ import { ReactComponent as CircleCheckIcon } from 'images/icons/circle-check.svg
import { ReactComponent as CircleCheckSolidIcon } from 'images/icons/circle-check-solid.svg';
import { ReactComponent as CloseIcon } from 'images/icons/close.svg';
import { ReactComponent as CompassIcon } from 'images/icons/compass.svg';
import { ReactComponent as DatabasesIcon } from 'images/icons/databases.svg';
import { ReactComponent as DatasetPhysicalIcon } from 'images/icons/dataset_physical.svg';
import { ReactComponent as DatasetVirtualIcon } from 'images/icons/dataset_virtual.svg';
import { ReactComponent as DropdownArrowIcon } from 'images/icons/dropdown-arrow.svg';
@ -57,6 +58,7 @@ type IconName =
| 'circle-check'
| 'close'
| 'compass'
| 'databases'
| 'dataset-physical'
| 'dataset-virtual'
| 'dropdown-arrow'
@ -85,6 +87,7 @@ export const iconsRegistry: Record<
'checkbox-on': CheckboxOnIcon,
'circle-check-solid': CircleCheckSolidIcon,
'circle-check': CircleCheckIcon,
databases: DatabasesIcon,
'dataset-physical': DatasetPhysicalIcon,
'dataset-virtual': DatasetVirtualIcon,
'favorite-selected': FavoriteSelectedIcon,
@ -106,6 +109,7 @@ export const iconsRegistry: Record<
trash: TrashIcon,
warning: WarningIcon,
};
interface IconProps extends SVGProps<SVGSVGElement> {
name: IconName;
}
@ -117,6 +121,7 @@ const Icon = ({
...rest
}: IconProps) => {
const Component = iconsRegistry[name];
return (
<Component color={color} viewBox={viewBox} data-test={name} {...rest} />
);

View File

@ -16,31 +16,88 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { SupersetClient } from '@superset-ui/connection';
import { t } from '@superset-ui/translation';
import React, { useEffect, useState } from 'react';
import { createErrorHandler } from 'src/views/CRUD/utils';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
import { commonMenuData } from 'src/views/CRUD/data/common';
import DatabaseModal, { DatabaseObject } from './DatabaseModal';
interface DatasourceListProps {
interface DatabaseListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
function DatasourceList({
addDangerToast,
addSuccessToast,
}: DatasourceListProps) {
function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
const [databaseModalOpen, setDatabaseModalOpen] = useState<boolean>(false);
const [currentDatabase, setCurrentDatabase] = useState<DatabaseObject | null>(
null,
);
const [permissions, setPermissions] = useState<string[]>([]);
const fetchDatasetInfo = () => {
SupersetClient.get({
endpoint: `/api/v1/dataset/_info`,
}).then(
({ json: infoJson = {} }) => {
setPermissions(infoJson.permissions);
},
createErrorHandler(errMsg =>
addDangerToast(t('An error occurred while fetching datasets', errMsg)),
),
);
};
useEffect(() => {
fetchDatasetInfo();
}, []);
const hasPerm = (perm: string) => {
if (!permissions.length) {
return false;
}
return Boolean(permissions.find(p => p === perm));
};
const canCreate = hasPerm('can_add');
const menuData: SubMenuProps = {
activeChild: 'Databases',
...commonMenuData,
};
if (canCreate) {
menuData.primaryButton = {
name: (
<>
{' '}
<i className="fa fa-plus" /> {t('Database')}{' '}
</>
),
onClick: () => {
// Ensure modal will be opened in add mode
setCurrentDatabase(null);
setDatabaseModalOpen(true);
},
};
}
return (
<>
<SubMenu {...menuData} />
<DatabaseModal
database={currentDatabase}
show={databaseModalOpen}
onHide={() => setDatabaseModalOpen(false)}
onDatabaseAdd={() => {
/* TODO: add database logic here */
}}
/>
</>
);
}
export default withToasts(DatasourceList);
export default withToasts(DatabaseList);

View File

@ -0,0 +1,231 @@
/**
* 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, useState } from 'react';
import styled from '@superset-ui/style';
import { t } from '@superset-ui/translation';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import Icon from 'src/components/Icon';
import Modal from 'src/common/components/Modal';
import Tabs from 'src/common/components/Tabs';
import { Tabs as BaseTabs } from 'src/common/components';
export type DatabaseObject = {
id?: number;
name: string;
uri: string;
// TODO: add more props
};
interface DatabaseModalProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
onDatabaseAdd?: (database?: DatabaseObject) => void; // TODO: should we add a separate function for edit?
onHide: () => void;
show: boolean;
database?: DatabaseObject | null; // If included, will go into edit mode
}
const { TabPane } = BaseTabs;
const StyledIcon = styled(Icon)`
margin: auto ${({ theme }) => theme.gridUnit * 2}px auto 0;
`;
const StyledInputContainer = styled.div`
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
.label,
.helper {
display: block;
padding: ${({ theme }) => theme.gridUnit}px 0;
color: ${({ theme }) => theme.colors.grayscale.light1};
font-size: ${({ theme }) => theme.typography.sizes.s}px;
text-align: left;
.required {
margin-left: ${({ theme }) => theme.gridUnit / 2}px;
color: ${({ theme }) => theme.colors.error.base};
}
}
.input-container {
display: flex;
}
input[type='text'] {
flex: 1 1 auto;
padding: ${({ theme }) => theme.gridUnit * 1.5}px
${({ theme }) => theme.gridUnit * 2}px;
border-style: none;
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
border-radius: ${({ theme }) => theme.gridUnit}px;
&[name='name'] {
flex: 0 1 auto;
width: 40%;
}
}
`;
const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
addDangerToast,
addSuccessToast,
onDatabaseAdd,
onHide,
show,
database = null,
}) => {
// const [disableSave, setDisableSave] = useState(true);
const [disableSave] = useState<boolean>(true);
const [db, setDB] = useState<DatabaseObject | null>(null);
const [isHidden, setIsHidden] = useState<boolean>(true);
// Functions
const hide = () => {
setIsHidden(true);
onHide();
};
const onSave = () => {
if (onDatabaseAdd) {
onDatabaseAdd();
}
hide();
};
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
const data = {
name: db ? db.name : '',
uri: db ? db.uri : '',
...db,
};
data[target.name] = target.value;
setDB(data);
};
const isEditMode = database !== null;
// Initialize
if (
isEditMode &&
(!db || !db.id || (database && database.id !== db.id) || (isHidden && show))
) {
setDB(database);
} else if (!isEditMode && (!db || db.id || (isHidden && show))) {
setDB({
name: '',
uri: '',
});
}
// Show/hide
if (isHidden && show) {
setIsHidden(false);
}
return (
<Modal
className="database-modal"
disablePrimaryButton={disableSave}
onHandledPrimaryAction={onSave}
onHide={hide}
primaryButtonName={isEditMode ? t('Save') : t('Add')}
width="750px"
show={show}
title={
<h4>
<StyledIcon name="databases" />
{isEditMode ? t('Edit Database') : t('Add Database')}
</h4>
}
>
<Tabs defaultActiveKey="1">
<TabPane
tab={
<span>
{t('Connection')}
<span className="required">*</span>
</span>
}
key="1"
>
<StyledInputContainer>
<div className="label">
{t('Datasource Name')}
<span className="required">*</span>
</div>
<div className="input-container">
<input
type="text"
name="name"
value={db ? db.name : ''}
placeholder={t('Name your datasource')}
onChange={onInputChange}
/>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="label">
{t('SQLAlchemy URI')}
<span className="required">*</span>
</div>
<div className="input-container">
<input
type="text"
name="uri"
value={db ? db.uri : ''}
placeholder={t('SQLAlchemy URI')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{t('Refer to the ')}
<a
href="https://docs.sqlalchemy.org/en/rel_1_2/core/engines.html#"
target="_blank"
rel="noopener noreferrer"
>
{t('SQLAlchemy docs')}
</a>
{t(' for more information on how to structure your URI.')}
</div>
</StyledInputContainer>
</TabPane>
<TabPane tab={<span>{t('Performance')}</span>} key="2">
Performance Form Data
</TabPane>
<TabPane tab={<span>{t('SQL Lab Settings')}</span>} key="3">
SQL Lab Settings Form Data
</TabPane>
<TabPane tab={<span>{t('Security')}</span>} key="4">
Security Form Data
</TabPane>
<TabPane tab={<span>{t('Extra')}</span>} key="5">
Extra Form Data
</TabPane>
</Tabs>
</Modal>
);
};
export default withToasts(DatabaseModal);

View File

@ -42,7 +42,7 @@ interface DatasetModalProps {
}
const StyledIcon = styled(Icon)`
margin: auto 10px auto 0;
margin: auto ${({ theme }) => theme.gridUnit * 2}px auto 0;
`;
const TableSelectorContainer = styled.div`