| /** |
| * 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 { SupersetClient, t, styled } from '@superset-ui/core'; |
| import React, { useState, useMemo } from 'react'; |
| import rison from 'rison'; |
| import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; |
| import { useListViewResource } from 'src/views/CRUD/hooks'; |
| import { createErrorHandler } from 'src/views/CRUD/utils'; |
| import withToasts from 'src/messageToasts/enhancers/withToasts'; |
| import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; |
| import DeleteModal from 'src/components/DeleteModal'; |
| import TooltipWrapper from 'src/components/TooltipWrapper'; |
| import Icon from 'src/components/Icon'; |
| import ListView, { Filters } from 'src/components/ListView'; |
| import { commonMenuData } from 'src/views/CRUD/data/common'; |
| import ImportModelsModal from 'src/components/ImportModal/index'; |
| import DatabaseModal from './DatabaseModal'; |
| import { DatabaseObject } from './types'; |
| |
| const PAGE_SIZE = 25; |
| const PASSWORDS_NEEDED_MESSAGE = t( |
| 'The passwords for the databases below are needed in order to ' + |
| 'import them. 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.', |
| ); |
| const CONFIRM_OVERWRITE_MESSAGE = t( |
| 'You are importing one or more databases that already exist. ' + |
| 'Overwriting might cause you to lose some of your work. Are you ' + |
| 'sure you want to overwrite?', |
| ); |
| |
| interface DatabaseDeleteObject extends DatabaseObject { |
| chart_count: number; |
| dashboard_count: number; |
| } |
| interface DatabaseListProps { |
| addDangerToast: (msg: string) => void; |
| addSuccessToast: (msg: string) => void; |
| } |
| |
| const IconBlack = styled(Icon)` |
| color: ${({ theme }) => theme.colors.grayscale.dark1}; |
| `; |
| |
| function BooleanDisplay({ value }: { value: Boolean }) { |
| return value ? <IconBlack name="check" /> : <IconBlack name="cancel-x" />; |
| } |
| |
| function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { |
| const { |
| state: { |
| loading, |
| resourceCount: databaseCount, |
| resourceCollection: databases, |
| }, |
| hasPerm, |
| fetchData, |
| refreshData, |
| } = useListViewResource<DatabaseObject>( |
| 'database', |
| t('database'), |
| addDangerToast, |
| ); |
| const [databaseModalOpen, setDatabaseModalOpen] = useState<boolean>(false); |
| const [ |
| databaseCurrentlyDeleting, |
| setDatabaseCurrentlyDeleting, |
| ] = useState<DatabaseDeleteObject | null>(null); |
| const [currentDatabase, setCurrentDatabase] = useState<DatabaseObject | null>( |
| null, |
| ); |
| const [importingDatabase, showImportModal] = useState<boolean>(false); |
| const [passwordFields, setPasswordFields] = useState<string[]>([]); |
| |
| const openDatabaseImportModal = () => { |
| showImportModal(true); |
| }; |
| |
| const closeDatabaseImportModal = () => { |
| showImportModal(false); |
| }; |
| |
| const handleDatabaseImport = () => { |
| showImportModal(false); |
| refreshData(); |
| }; |
| |
| const openDatabaseDeleteModal = (database: DatabaseObject) => |
| SupersetClient.get({ |
| endpoint: `/api/v1/database/${database.id}/related_objects/`, |
| }) |
| .then(({ json = {} }) => { |
| setDatabaseCurrentlyDeleting({ |
| ...database, |
| chart_count: json.charts.count, |
| dashboard_count: json.dashboards.count, |
| }); |
| }) |
| .catch( |
| createErrorHandler(errMsg => |
| t( |
| 'An error occurred while fetching database related data: %s', |
| errMsg, |
| ), |
| ), |
| ); |
| |
| function handleDatabaseDelete({ id, database_name: dbName }: DatabaseObject) { |
| SupersetClient.delete({ |
| endpoint: `/api/v1/database/${id}`, |
| }).then( |
| () => { |
| refreshData(); |
| addSuccessToast(t('Deleted: %s', dbName)); |
| |
| // Close delete modal |
| setDatabaseCurrentlyDeleting(null); |
| }, |
| createErrorHandler(errMsg => |
| addDangerToast(t('There was an issue deleting %s: %s', dbName, errMsg)), |
| ), |
| ); |
| } |
| |
| function handleDatabaseEdit(database: DatabaseObject) { |
| // Set database and open modal |
| setCurrentDatabase(database); |
| setDatabaseModalOpen(true); |
| } |
| |
| const canCreate = hasPerm('can_write'); |
| const canEdit = hasPerm('can_write'); |
| const canDelete = hasPerm('can_write'); |
| const canExport = |
| hasPerm('can_read') && isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT); |
| |
| const menuData: SubMenuProps = { |
| activeChild: 'Databases', |
| ...commonMenuData, |
| }; |
| |
| if (canCreate) { |
| menuData.buttons = [ |
| { |
| 'data-test': 'btn-create-database', |
| name: ( |
| <> |
| <i className="fa fa-plus" /> {t('Database')}{' '} |
| </> |
| ), |
| buttonStyle: 'primary', |
| onClick: () => { |
| // Ensure modal will be opened in add mode |
| setCurrentDatabase(null); |
| setDatabaseModalOpen(true); |
| }, |
| }, |
| ]; |
| |
| if (isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT)) { |
| menuData.buttons.push({ |
| name: <Icon name="import" />, |
| buttonStyle: 'link', |
| onClick: openDatabaseImportModal, |
| }); |
| } |
| } |
| |
| function handleDatabaseExport(database: DatabaseObject) { |
| return window.location.assign( |
| `/api/v1/database/export/?q=${rison.encode([database.id])}`, |
| ); |
| } |
| |
| const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; |
| const columns = useMemo( |
| () => [ |
| { |
| accessor: 'database_name', |
| Header: t('Database'), |
| }, |
| { |
| accessor: 'backend', |
| Header: t('Backend'), |
| size: 'lg', |
| disableSortBy: true, // TODO: api support for sorting by 'backend' |
| }, |
| { |
| accessor: 'allow_run_async', |
| Header: ( |
| <TooltipWrapper |
| label="allow-run-async-header" |
| tooltip={t('Asynchronous query execution')} |
| placement="top" |
| > |
| <span>{t('AQE')}</span> |
| </TooltipWrapper> |
| ), |
| Cell: ({ |
| row: { |
| original: { allow_run_async: allowRunAsync }, |
| }, |
| }: any) => <BooleanDisplay value={allowRunAsync} />, |
| size: 'sm', |
| }, |
| { |
| accessor: 'allow_dml', |
| Header: ( |
| <TooltipWrapper |
| label="allow-dml-header" |
| tooltip={t('Allow data manipulation language')} |
| placement="top" |
| > |
| <span>{t('DML')}</span> |
| </TooltipWrapper> |
| ), |
| Cell: ({ |
| row: { |
| original: { allow_dml: allowDML }, |
| }, |
| }: any) => <BooleanDisplay value={allowDML} />, |
| size: 'sm', |
| }, |
| { |
| accessor: 'allow_csv_upload', |
| Header: t('CSV upload'), |
| Cell: ({ |
| row: { |
| original: { allow_csv_upload: allowCSVUpload }, |
| }, |
| }: any) => <BooleanDisplay value={allowCSVUpload} />, |
| size: 'md', |
| }, |
| { |
| accessor: 'expose_in_sqllab', |
| Header: t('Expose in SQL Lab'), |
| Cell: ({ |
| row: { |
| original: { expose_in_sqllab: exposeInSqllab }, |
| }, |
| }: any) => <BooleanDisplay value={exposeInSqllab} />, |
| size: 'md', |
| }, |
| { |
| accessor: 'created_by', |
| disableSortBy: true, |
| Header: t('Created by'), |
| Cell: ({ |
| row: { |
| original: { created_by: createdBy }, |
| }, |
| }: any) => |
| createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '', |
| size: 'xl', |
| }, |
| { |
| Cell: ({ |
| row: { |
| original: { changed_on_delta_humanized: changedOn }, |
| }, |
| }: any) => changedOn, |
| Header: t('Last modified'), |
| accessor: 'changed_on_delta_humanized', |
| size: 'xl', |
| }, |
| { |
| Cell: ({ row: { original } }: any) => { |
| const handleEdit = () => handleDatabaseEdit(original); |
| const handleDelete = () => openDatabaseDeleteModal(original); |
| const handleExport = () => handleDatabaseExport(original); |
| if (!canEdit && !canDelete && !canExport) { |
| return null; |
| } |
| return ( |
| <span className="actions"> |
| {canDelete && ( |
| <span |
| role="button" |
| tabIndex={0} |
| className="action-button" |
| data-test="database-delete" |
| onClick={handleDelete} |
| > |
| <TooltipWrapper |
| label="delete-action" |
| tooltip={t('Delete database')} |
| placement="bottom" |
| > |
| <Icon name="trash" /> |
| </TooltipWrapper> |
| </span> |
| )} |
| {canExport && ( |
| <TooltipWrapper |
| label="export-action" |
| tooltip={t('Export')} |
| placement="bottom" |
| > |
| <span |
| role="button" |
| tabIndex={0} |
| className="action-button" |
| onClick={handleExport} |
| > |
| <Icon name="share" /> |
| </span> |
| </TooltipWrapper> |
| )} |
| {canEdit && ( |
| <TooltipWrapper |
| label="edit-action" |
| tooltip={t('Edit')} |
| placement="bottom" |
| > |
| <span |
| role="button" |
| data-test="database-edit" |
| tabIndex={0} |
| className="action-button" |
| onClick={handleEdit} |
| > |
| <Icon name="edit-alt" /> |
| </span> |
| </TooltipWrapper> |
| )} |
| </span> |
| ); |
| }, |
| Header: t('Actions'), |
| id: 'actions', |
| hidden: !canEdit && !canDelete, |
| disableSortBy: true, |
| }, |
| ], |
| [canDelete, canEdit, canExport], |
| ); |
| |
| const filters: Filters = useMemo( |
| () => [ |
| { |
| Header: t('Expose in SQL Lab'), |
| id: 'expose_in_sqllab', |
| input: 'select', |
| operator: 'eq', |
| unfilteredLabel: 'All', |
| selects: [ |
| { label: 'Yes', value: true }, |
| { label: 'No', value: false }, |
| ], |
| }, |
| { |
| Header: ( |
| <TooltipWrapper |
| label="allow-run-async-filter-header" |
| tooltip={t('Asynchronous query execution')} |
| placement="top" |
| > |
| <span>{t('AQE')}</span> |
| </TooltipWrapper> |
| ), |
| id: 'allow_run_async', |
| input: 'select', |
| operator: 'eq', |
| unfilteredLabel: 'All', |
| selects: [ |
| { label: 'Yes', value: true }, |
| { label: 'No', value: false }, |
| ], |
| }, |
| { |
| Header: t('Search'), |
| id: 'database_name', |
| input: 'search', |
| operator: 'ct', |
| }, |
| ], |
| [], |
| ); |
| |
| return ( |
| <> |
| <SubMenu {...menuData} /> |
| <DatabaseModal |
| database={currentDatabase} |
| show={databaseModalOpen} |
| onHide={() => setDatabaseModalOpen(false)} |
| onDatabaseAdd={() => { |
| refreshData(); |
| }} |
| /> |
| {databaseCurrentlyDeleting && ( |
| <DeleteModal |
| description={t( |
| 'The database %s is linked to %s charts that appear on %s dashboards. Are you sure you want to continue? Deleting the database will break those objects.', |
| databaseCurrentlyDeleting.database_name, |
| databaseCurrentlyDeleting.chart_count, |
| databaseCurrentlyDeleting.dashboard_count, |
| )} |
| onConfirm={() => { |
| if (databaseCurrentlyDeleting) { |
| handleDatabaseDelete(databaseCurrentlyDeleting); |
| } |
| }} |
| onHide={() => setDatabaseCurrentlyDeleting(null)} |
| open |
| title={t('Delete Database?')} |
| /> |
| )} |
| |
| <ListView<DatabaseObject> |
| className="database-list-view" |
| columns={columns} |
| count={databaseCount} |
| data={databases} |
| fetchData={fetchData} |
| filters={filters} |
| initialSort={initialSort} |
| loading={loading} |
| pageSize={PAGE_SIZE} |
| /> |
| |
| <ImportModelsModal |
| resourceName="database" |
| resourceLabel={t('database')} |
| passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE} |
| confirmOverwriteMessage={CONFIRM_OVERWRITE_MESSAGE} |
| addDangerToast={addDangerToast} |
| addSuccessToast={addSuccessToast} |
| onModelImport={handleDatabaseImport} |
| show={importingDatabase} |
| onHide={closeDatabaseImportModal} |
| passwordFields={passwordFields} |
| setPasswordFields={setPasswordFields} |
| /> |
| </> |
| ); |
| } |
| |
| export default withToasts(DatabaseList); |