diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx index 2c01e6c106e22..12580d8ee73fa 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx @@ -18,6 +18,7 @@ */ import React from 'react'; import thunk from 'redux-thunk'; +import * as redux from 'react-redux'; import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; import { Provider } from 'react-redux'; @@ -40,6 +41,17 @@ import { act } from 'react-dom/test-utils'; const mockStore = configureStore([thunk]); const store = mockStore({}); +const mockAppState = { + common: { + config: { + CSV_EXTENSIONS: ['csv'], + EXCEL_EXTENSIONS: ['xls', 'xlsx'], + COLUMNAR_EXTENSIONS: ['parquet', 'zip'], + ALLOWED_EXTENSIONS: ['parquet', 'zip', 'xls', 'xlsx', 'csv'], + }, + }, +}; + const databasesInfoEndpoint = 'glob:*/api/v1/database/_info*'; const databasesEndpoint = 'glob:*/api/v1/database/?*'; const databaseEndpoint = 'glob:*/api/v1/database/*'; @@ -94,12 +106,22 @@ fetchMock.get(databaseRelatedEndpoint, { }, }); +const useSelectorMock = jest.spyOn(redux, 'useSelector'); + describe('DatabaseList', () => { + useSelectorMock.mockReturnValue({ + CSV_EXTENSIONS: ['csv'], + EXCEL_EXTENSIONS: ['xls', 'xlsx'], + COLUMNAR_EXTENSIONS: ['parquet', 'zip'], + ALLOWED_EXTENSIONS: ['parquet', 'zip', 'xls', 'xlsx', 'csv'], + }); + const wrapper = mount( , ); + beforeAll(async () => { await waitForComponentToPaint(wrapper); }); @@ -195,6 +217,7 @@ describe('RTL', () => { , { useRedux: true }, + mockAppState, ); }); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index 08f75a6bac5e5..10149bc9e8a16 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx @@ -18,10 +18,11 @@ */ import { SupersetClient, t, styled } from '@superset-ui/core'; import React, { useState, useMemo } from 'react'; +import { useSelector } from 'react-redux'; import Loading from 'src/components/Loading'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import { useListViewResource } from 'src/views/CRUD/hooks'; -import { createErrorHandler } from 'src/views/CRUD/utils'; +import { createErrorHandler, uploadUserPerms } from 'src/views/CRUD/utils'; import withToasts from 'src/components/MessageToasts/withToasts'; import SubMenu, { SubMenuProps } from 'src/views/components/SubMenu'; import DeleteModal from 'src/components/DeleteModal'; @@ -31,6 +32,8 @@ import ListView, { FilterOperator, Filters } from 'src/components/ListView'; import { commonMenuData } from 'src/views/CRUD/data/common'; import ImportModelsModal from 'src/components/ImportModal/index'; import handleResourceExport from 'src/utils/export'; +import { ExtentionConfigs } from 'src/views/components/types'; +import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import DatabaseModal from './DatabaseModal'; import { DatabaseObject } from './types'; @@ -103,6 +106,15 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { const [importingDatabase, showImportModal] = useState(false); const [passwordFields, setPasswordFields] = useState([]); const [preparingExport, setPreparingExport] = useState(false); + const { roles } = useSelector( + state => state.user, + ); + const { + CSV_EXTENSIONS, + COLUMNAR_EXTENSIONS, + EXCEL_EXTENSIONS, + ALLOWED_EXTENSIONS, + } = useSelector(state => state.common.conf); const openDatabaseImportModal = () => { showImportModal(true); @@ -171,8 +183,49 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { const canExport = hasPerm('can_export') && isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT); + const { canUploadCSV, canUploadColumnar, canUploadExcel } = uploadUserPerms( + roles, + CSV_EXTENSIONS, + COLUMNAR_EXTENSIONS, + EXCEL_EXTENSIONS, + ALLOWED_EXTENSIONS, + ); + + const uploadDropdownMenu = [ + { + label: t('Upload file to database'), + childs: [ + { + label: t('Upload CSV'), + name: 'Upload CSV file', + url: '/csvtodatabaseview/form', + perm: canUploadCSV, + }, + { + label: t('Upload columnar file'), + name: 'Upload columnar file', + url: '/columnartodatabaseview/form', + perm: canUploadColumnar, + }, + { + label: t('Upload Excel file'), + name: 'Upload Excel file', + url: '/exceltodatabaseview/form', + perm: canUploadExcel, + }, + ], + }, + ]; + + const filteredDropDown = uploadDropdownMenu.map(link => { + // eslint-disable-next-line no-param-reassign + link.childs = link.childs.filter(item => item.perm); + return link; + }); + const menuData: SubMenuProps = { activeChild: 'Databases', + dropDownLinks: filteredDropDown, ...commonMenuData, }; @@ -222,6 +275,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { } const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; + const columns = useMemo( () => [ { diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index c2ae0d8cbed13..c6c5282738c60 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -32,6 +32,7 @@ import rison from 'rison'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import { FetchDataConfig } from 'src/components/ListView'; import SupersetText from 'src/utils/textUtils'; +import findPermission from 'src/dashboard/util/findPermission'; import { Dashboard, Filters } from './types'; // Modifies the rison encoding slightly to match the backend's rison encoding/decoding. Applies globally. @@ -420,3 +421,21 @@ export const checkUploadExtensions = ( } return false; }; + +export const uploadUserPerms = ( + roles: Record, + csvExt: Array, + colExt: Array, + excelExt: Array, + allowedExt: Array, +) => ({ + canUploadCSV: + findPermission('can_this_form_get', 'CsvToDatabaseView', roles) && + checkUploadExtensions(csvExt, allowedExt), + canUploadColumnar: + checkUploadExtensions(colExt, allowedExt) && + findPermission('can_this_form_get', 'ColumnarToDatabaseView', roles), + canUploadExcel: + checkUploadExtensions(excelExt, allowedExt) && + findPermission('can_this_form_get', 'ExcelToDatabaseView', roles), +}); diff --git a/superset-frontend/src/views/components/Menu.tsx b/superset-frontend/src/views/components/Menu.tsx index 77a074f71b6c7..e690dd13c159d 100644 --- a/superset-frontend/src/views/components/Menu.tsx +++ b/superset-frontend/src/views/components/Menu.tsx @@ -67,7 +67,7 @@ export interface MenuProps { isFrontendRoute?: (path?: string) => boolean; } -interface MenuObjectChildProps { +export interface MenuObjectChildProps { label: string; name?: string; icon?: string; diff --git a/superset-frontend/src/views/components/MenuRight.tsx b/superset-frontend/src/views/components/MenuRight.tsx index ab5b5d8d82a7e..5753ef5311c0c 100644 --- a/superset-frontend/src/views/components/MenuRight.tsx +++ b/superset-frontend/src/views/components/MenuRight.tsx @@ -26,7 +26,7 @@ import { useSelector } from 'react-redux'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import LanguagePicker from './LanguagePicker'; import DatabaseModal from '../CRUD/data/database/DatabaseModal'; -import { checkUploadExtensions } from '../CRUD/utils'; +import { uploadUserPerms } from '../CRUD/utils'; import { ExtentionConfigs, GlobalMenuDataOptions, @@ -86,20 +86,12 @@ const RightMenu = ({ const canChart = findPermission('can_write', 'Chart', roles); const canDatabase = findPermission('can_write', 'Database', roles); - const canUploadCSV = findPermission( - 'can_this_form_get', - 'CsvToDatabaseView', - roles, - ); - const canUploadColumnar = findPermission( - 'can_this_form_get', - 'ColumnarToDatabaseView', - roles, - ); - const canUploadExcel = findPermission( - 'can_this_form_get', - 'ExcelToDatabaseView', + const { canUploadCSV, canUploadColumnar, canUploadExcel } = uploadUserPerms( roles, + CSV_EXTENSIONS, + COLUMNAR_EXTENSIONS, + EXCEL_EXTENSIONS, + ALLOWED_EXTENSIONS, ); const canUpload = canUploadCSV || canUploadColumnar || canUploadExcel; @@ -123,25 +115,19 @@ const RightMenu = ({ label: t('Upload CSV to database'), name: 'Upload a CSV', url: '/csvtodatabaseview/form', - perm: - checkUploadExtensions(CSV_EXTENSIONS, ALLOWED_EXTENSIONS) && - canUploadCSV, + perm: canUploadCSV, }, { label: t('Upload columnar file to database'), name: 'Upload a Columnar file', url: '/columnartodatabaseview/form', - perm: - checkUploadExtensions(COLUMNAR_EXTENSIONS, ALLOWED_EXTENSIONS) && - canUploadColumnar, + perm: canUploadColumnar, }, { label: t('Upload Excel file to database'), name: 'Upload Excel', url: '/exceltodatabaseview/form', - perm: - checkUploadExtensions(EXCEL_EXTENSIONS, ALLOWED_EXTENSIONS) && - canUploadExcel, + perm: canUploadExcel, }, ], }, diff --git a/superset-frontend/src/views/components/SubMenu.test.tsx b/superset-frontend/src/views/components/SubMenu.test.tsx index a7c2a557f38af..6359e03cc261f 100644 --- a/superset-frontend/src/views/components/SubMenu.test.tsx +++ b/superset-frontend/src/views/components/SubMenu.test.tsx @@ -43,6 +43,19 @@ const mockedProps = { usesRouter: false, }, ], + dropDownLinks: [ + { + label: 'test a upload', + childs: [ + { + label: 'Upload Test', + name: 'Upload Test', + url: '/test/form', + perm: true, + }, + ], + }, + ], }; test('should render', () => { @@ -74,6 +87,13 @@ test('should render all the tabs links', () => { }); }); +test('should render dropdownlinks', async () => { + render(); + userEvent.hover(screen.getByText('test a upload')); + const label = await screen.findByText('test a upload'); + expect(label).toBeInTheDocument(); +}); + test('should render the buttons', () => { const mockFunc = jest.fn(); const buttons = [ @@ -94,7 +114,7 @@ test('should render the buttons', () => { }; render(); const testButton = screen.getByText(buttons[0].name); - expect(screen.getAllByRole('button')).toHaveLength(2); + expect(screen.getAllByRole('button')).toHaveLength(3); userEvent.click(testButton); expect(mockFunc).toHaveBeenCalled(); }); diff --git a/superset-frontend/src/views/components/SubMenu.tsx b/superset-frontend/src/views/components/SubMenu.tsx index d0795307e928e..4ad3cfe42ede5 100644 --- a/superset-frontend/src/views/components/SubMenu.tsx +++ b/superset-frontend/src/views/components/SubMenu.tsx @@ -22,8 +22,10 @@ import { styled } from '@superset-ui/core'; import cx from 'classnames'; import { debounce } from 'lodash'; import { Row } from 'src/components'; -import { Menu, MenuMode } from 'src/components/Menu'; +import { Menu, MenuMode, MainNav as DropdownMenu } from 'src/components/Menu'; import Button, { OnClickHandler } from 'src/components/Button'; +import Icons from 'src/components/Icons'; +import { MenuObjectProps } from './Menu'; const StyledHeader = styled.div` margin-bottom: ${({ theme }) => theme.gridUnit * 4}px; @@ -39,11 +41,21 @@ const StyledHeader = styled.div` .nav-right { display: flex; align-items: center; - padding: 14px 0; + padding: ${({ theme }) => theme.gridUnit * 3.5}px 0; margin-right: ${({ theme }) => theme.gridUnit * 3}px; float: right; position: absolute; right: 0; + ul.ant-menu-root { + padding: 0px; + } + li[role='menuitem'] { + border: 0; + border-bottom: none; + &:hover { + border-bottom: transparent; + } + } } .nav-right-collapse { display: flex; @@ -58,8 +70,10 @@ const StyledHeader = styled.div` .ant-menu-horizontal { line-height: inherit; .ant-menu-item { + border-bottom: none; &:hover { border-bottom: none; + text-decoration: none; } } } @@ -117,6 +131,17 @@ const StyledHeader = styled.div` margin-left: ${({ theme }) => theme.gridUnit * 2}px; } } + .ant-menu-submenu { + span[role='img'] { + position: absolute; + right: ${({ theme }) => -theme.gridUnit + -2}px; + top: ${({ theme }) => theme.gridUnit + 1}px !important; + } + } + .dropdown-menu-links > div.ant-menu-submenu-title, + .ant-menu-submenu-open.ant-menu-submenu-active > div.ant-menu-submenu-title { + color: ${({ theme }) => theme.colors.primary.dark1}; + } `; type MenuChild = { @@ -152,8 +177,11 @@ export interface SubMenuProps { * otherwise, a 'You should not use outside a ' error will be thrown */ usesRouter?: boolean; color?: string; + dropDownLinks?: Array; } +const { SubMenu } = DropdownMenu; + const SubMenuComponent: React.FunctionComponent = props => { const [showMenu, setMenu] = useState('horizontal'); const [navRightStyle, setNavRightStyle] = useState('nav-right'); @@ -177,6 +205,7 @@ const SubMenuComponent: React.FunctionComponent = props => { props.buttons.length >= 3 && window.innerWidth >= 795 ) { + // eslint-disable-next-line no-unused-expressions setNavRightStyle('nav-right'); } else if ( props.buttons && @@ -231,6 +260,28 @@ const SubMenuComponent: React.FunctionComponent = props => { })} + + {props.dropDownLinks?.map((link, i) => ( + } + popupOffset={[10, 20]} + className="dropdown-menu-links" + > + {link.childs?.map(item => { + if (typeof item === 'object') { + return ( + + {item.label} + + ); + } + return null; + })} + + ))} + {props.buttons?.map((btn, i) => (