diff --git a/superset-frontend/images/icons/databases.svg b/superset-frontend/images/icons/databases.svg new file mode 100644 index 0000000000000..3464e04d7a6ea --- /dev/null +++ b/superset-frontend/images/icons/databases.svg @@ -0,0 +1,21 @@ + + + + diff --git a/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx index c85ab825d7597..c8fb96ac45c61 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx @@ -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(); + }); }); diff --git a/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseModal_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseModal_spec.jsx new file mode 100644 index 0000000000000..8b8e29b98943e --- /dev/null +++ b/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseModal_spec.jsx @@ -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(, { context: { store } }); + + it('renders', () => { + expect(wrapper.find(DatabaseModal)).toExist(); + }); + + it('renders a Modal', () => { + expect(wrapper.find(Modal)).toExist(); + }); +}); diff --git a/superset-frontend/src/common/components/Modal.tsx b/superset-frontend/src/common/components/Modal.tsx new file mode 100644 index 0000000000000..ab29c9fce0efe --- /dev/null +++ b/superset-frontend/src/common/components/Modal.tsx @@ -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 ( + + ); +} diff --git a/superset-frontend/src/common/components/Tabs.tsx b/superset-frontend/src/common/components/Tabs.tsx new file mode 100644 index 0000000000000..dde0102fa5e4f --- /dev/null +++ b/superset-frontend/src/common/components/Tabs.tsx @@ -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; diff --git a/superset-frontend/src/components/Icon/index.tsx b/superset-frontend/src/components/Icon/index.tsx index 2107de8b9a757..f033fd81341f1 100644 --- a/superset-frontend/src/components/Icon/index.tsx +++ b/superset-frontend/src/components/Icon/index.tsx @@ -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 { name: IconName; } @@ -117,6 +121,7 @@ const Icon = ({ ...rest }: IconProps) => { const Component = iconsRegistry[name]; + return ( ); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index b13c211a1b272..fb0edbc5bc650 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx @@ -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(false); + const [currentDatabase, setCurrentDatabase] = useState( + null, + ); + const [permissions, setPermissions] = useState([]); + + 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: ( + <> + {' '} + {t('Database')}{' '} + + ), + onClick: () => { + // Ensure modal will be opened in add mode + setCurrentDatabase(null); + setDatabaseModalOpen(true); + }, + }; + } + return ( <> + setDatabaseModalOpen(false)} + onDatabaseAdd={() => { + /* TODO: add database logic here */ + }} + /> ); } -export default withToasts(DatasourceList); +export default withToasts(DatabaseList); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx new file mode 100644 index 0000000000000..4944e2a35fd2d --- /dev/null +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx @@ -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 = ({ + addDangerToast, + addSuccessToast, + onDatabaseAdd, + onHide, + show, + database = null, +}) => { + // const [disableSave, setDisableSave] = useState(true); + const [disableSave] = useState(true); + const [db, setDB] = useState(null); + const [isHidden, setIsHidden] = useState(true); + + // Functions + const hide = () => { + setIsHidden(true); + onHide(); + }; + + const onSave = () => { + if (onDatabaseAdd) { + onDatabaseAdd(); + } + + hide(); + }; + + const onInputChange = (event: React.ChangeEvent) => { + 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 ( + + + {isEditMode ? t('Edit Database') : t('Add Database')} + + } + > + + + {t('Connection')} + * + + } + key="1" + > + +
+ {t('Datasource Name')} + * +
+
+ +
+
+ +
+ {t('SQLAlchemy URI')} + * +
+
+ +
+
+ {t('Refer to the ')} + + {t('SQLAlchemy docs')} + + {t(' for more information on how to structure your URI.')} +
+
+
+ {t('Performance')}} key="2"> + Performance Form Data + + {t('SQL Lab Settings')}} key="3"> + SQL Lab Settings Form Data + + {t('Security')}} key="4"> + Security Form Data + + {t('Extra')}} key="5"> + Extra Form Data + +
+
+ ); +}; + +export default withToasts(DatabaseModal); diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx index 045837ceac87c..17c5182544d83 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx @@ -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`