diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx index edc6d749501a8..d4b1470f38f1c 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx @@ -17,7 +17,6 @@ * under the License. */ import React, { FunctionComponent, useState, useEffect } from 'react'; -import { useHistory } from 'react-router-dom'; import { styled, t } from '@superset-ui/core'; import { useSingleViewResource } from 'src/views/CRUD/hooks'; import Modal from 'src/components/Modal'; @@ -29,6 +28,7 @@ import { LocalStorageKeys, setItem, } from 'src/utils/localStorageHelpers'; +import { isEmpty } from 'lodash'; type DatasetAddObject = { id: number; @@ -42,6 +42,7 @@ interface DatasetModalProps { onDatasetAdd?: (dataset: DatasetAddObject) => void; onHide: () => void; show: boolean; + history?: any; // So we can render the modal when not using SPA } const TableSelectorContainer = styled.div` @@ -54,8 +55,8 @@ const DatasetModal: FunctionComponent = ({ onDatasetAdd, onHide, show, + history, }) => { - const history = useHistory(); const [currentDatabase, setCurrentDatabase] = useState< DatabaseObject | undefined >(); @@ -128,8 +129,16 @@ const DatasetModal: FunctionComponent = ({ if (onDatasetAdd) { onDatasetAdd({ id: response.id, ...response }); } - history.push(`/chart/add?dataset=${currentTableName}`); - cleanup(); + // We need to be able to work with no SPA routes opening the modal + // So useHistory wont be available always thus we check for it + if (!isEmpty(history)) { + history?.push(`/chart/add?dataset=${currentTableName}`); + cleanup(); + } else { + window.location.href = `/chart/add?dataset=${currentTableName}`; + cleanup(); + onHide(); + } }); }; diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx index df2f54cb0f270..f937056a6a43b 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx @@ -725,6 +725,7 @@ const DatasetList: FunctionComponent = ({ show={datasetAddModalOpen} onHide={closeDatasetAddModal} onDatasetAdd={refreshData} + history={history} /> {datasetCurrentlyDeleting && ( ({ ...jest.requireActual('react-redux'), useSelector: jest.fn(), })); +jest.mock('src/views/CRUD/data/database/DatabaseModal', () => () => ); +jest.mock('src/views/CRUD/data/dataset/AddDatasetModal.tsx', () => () => ( + +)); + +const dropdownItems = [ + { + label: 'Data', + icon: 'fa-database', + childs: [ + { + label: 'Connect database', + name: GlobalMenuDataOptions.DB_CONNECTION, + perm: true, + }, + { + label: 'Create dataset', + name: GlobalMenuDataOptions.DATASET_CREATION, + perm: true, + }, + { + label: 'Connect Google Sheet', + name: GlobalMenuDataOptions.GOOGLE_SHEETS, + perm: true, + }, + { + label: 'Upload CSV to database', + name: 'Upload a CSV', + url: '/csvtodatabaseview/form', + perm: true, + }, + { + label: 'Upload columnar file to database', + name: 'Upload a Columnar file', + url: '/columnartodatabaseview/form', + perm: true, + }, + { + label: 'Upload Excel file to database', + name: 'Upload Excel', + url: '/exceltodatabaseview/form', + perm: true, + }, + ], + }, + { + label: 'SQL query', + url: '/superset/sqllab?new=true', + icon: 'fa-fw fa-search', + perm: 'can_sqllab', + view: 'Superset', + }, + { + label: 'Chart', + url: '/chart/add', + icon: 'fa-fw fa-bar-chart', + perm: 'can_write', + view: 'Chart', + }, + { + label: 'Dashboard', + url: '/dashboard/new', + icon: 'fa-fw fa-dashboard', + perm: 'can_write', + view: 'Dashboard', + }, +]; + const createProps = (): RightMenuProps => ({ align: 'flex-end', navbarRight: { show_watermark: false, - bug_report_url: '/report/', - documentation_url: '/docs/', + bug_report_url: undefined, + documentation_url: undefined, languages: { en: { flag: 'us', @@ -47,8 +115,8 @@ const createProps = (): RightMenuProps => ({ url: '/lang/it', }, }, - show_language_picker: true, - user_is_anonymous: true, + show_language_picker: false, + user_is_anonymous: false, user_info_url: '/users/userinfo/', user_logout_url: '/logout/', user_login_url: '/login/', @@ -58,38 +126,15 @@ const createProps = (): RightMenuProps => ({ version_sha: 'randomSHA', build_number: 'randomBuildNumber', }, - settings: [ - { - name: 'Security', - icon: 'fa-cogs', - label: 'Security', - index: 1, - childs: [ - { - name: 'List Users', - icon: 'fa-user', - label: 'List Users', - url: '/users/list/', - index: 1, - }, - ], - }, - ], + settings: [], isFrontendRoute: () => true, environmentTag: { color: 'error.base', - text: 'Development', + text: 'Development2', }, }); -const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); -const useStateMock = jest.spyOn(React, 'useState'); - -let setShowModal: any; -let setEngine: any; -let setAllowUploads: any; - -const mockNonGSheetsDBs = [...new Array(2)].map((_, i) => ({ +const mockNonExamplesDB = [...new Array(2)].map((_, i) => ({ changed_by: { first_name: `user`, last_name: `${i}`, @@ -108,161 +153,205 @@ const mockNonGSheetsDBs = [...new Array(2)].map((_, i) => ({ }, })); -const mockGsheetsDbs = [...new Array(2)].map((_, i) => ({ - changed_by: { - first_name: `user`, - last_name: `${i}`, - }, - database_name: `db ${i}`, - backend: 'gsheets', - allow_run_async: true, - allow_dml: false, - allow_file_upload: true, - expose_in_sqllab: false, - changed_on_delta_humanized: `${i} day(s) ago`, - changed_on: new Date().toISOString, - id: i, - engine_information: { - supports_file_upload: false, - }, -})); +const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); -describe('RightMenu', () => { - const mockedProps = createProps(); +beforeEach(async () => { + useSelectorMock.mockReset(); + fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + { result: [], count: 0 }, + ); + fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:database_name,opr:neq,value:examples)))', + { result: [], count: 0 }, + ); +}); - beforeEach(async () => { - useSelectorMock.mockReset(); - useStateMock.mockReset(); - fetchMock.get( - 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', - { result: [], database_count: 0 }, - ); - // By default we get file extensions to be uploaded - useSelectorMock.mockReturnValue({ - CSV_EXTENSIONS: ['csv'], - EXCEL_EXTENSIONS: ['xls', 'xlsx'], - COLUMNAR_EXTENSIONS: ['parquet', 'zip'], - ALLOWED_EXTENSIONS: ['parquet', 'zip', 'xls', 'xlsx', 'csv'], - }); - setShowModal = jest.fn(); - setEngine = jest.fn(); - setAllowUploads = jest.fn(); - const mockSetStateModal: any = (x: any) => [x, setShowModal]; - const mockSetStateEngine: any = (x: any) => [x, setEngine]; - const mockSetStateAllow: any = (x: any) => [x, setAllowUploads]; - useStateMock.mockImplementationOnce(mockSetStateModal); - useStateMock.mockImplementationOnce(mockSetStateEngine); - useStateMock.mockImplementationOnce(mockSetStateAllow); +afterEach(fetchMock.restore); + +const resetUseSelectorMock = () => { + useSelectorMock.mockReturnValueOnce({ + createdOn: '2021-04-27T18:12:38.952304', + email: 'admin', + firstName: 'admin', + isActive: true, + lastName: 'admin', + permissions: {}, + roles: { + Admin: [ + ['can_this_form_get', 'CsvToDatabaseView'], // So we can upload CSV + ['can_write', 'Database'], // So we can write DBs + ['can_write', 'Dataset'], // So we can write Datasets + ['can_write', 'Chart'], // So we can write Datasets + ], + }, + userId: 1, + username: 'admin', }); - afterEach(fetchMock.restore); - it('renders', async () => { - const wrapper = mount(); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(RightMenu)).toExist(); + + // By default we get file extensions to be uploaded + useSelectorMock.mockReturnValueOnce('1'); + // By default we get file extensions to be uploaded + useSelectorMock.mockReturnValueOnce({ + CSV_EXTENSIONS: ['csv'], + EXCEL_EXTENSIONS: ['xls', 'xlsx'], + COLUMNAR_EXTENSIONS: ['parquet', 'zip'], + ALLOWED_EXTENSIONS: ['parquet', 'zip', 'xls', 'xlsx', 'csv'], }); - it('If user has permission to upload files we query the existing DBs that has allow_file_upload as True', async () => { - useSelectorMock.mockReturnValueOnce({ - createdOn: '2021-04-27T18:12:38.952304', - email: 'admin', - firstName: 'admin', - isActive: true, - lastName: 'admin', - permissions: {}, - roles: { - Admin: [ - ['can_this_form_get', 'CsvToDatabaseView'], // So we can upload CSV - ], - }, - userId: 1, - username: 'admin', - }); - // Second call we get the dashboardId - useSelectorMock.mockReturnValueOnce('1'); - const wrapper = mount(); - await waitForComponentToPaint(wrapper); - const callsD = fetchMock.calls(/database\/\?q/); - expect(callsD).toHaveLength(1); - expect(callsD[0][0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))"`, - ); +}; + +test('renders', async () => { + const mockedProps = createProps(); + // Initial Load + resetUseSelectorMock(); + const { container } = render(, { + useRedux: true, + useQueryParams: true, }); - it('If user has no permission to upload files the query API should not be called', async () => { - useSelectorMock.mockReturnValueOnce({ - createdOn: '2021-04-27T18:12:38.952304', - email: 'admin', - firstName: 'admin', - isActive: true, - lastName: 'admin', - permissions: {}, - roles: { - Admin: [['can_write', 'Chart']], // no file permissions - }, - userId: 1, - username: 'admin', - }); - // Second call we get the dashboardId - useSelectorMock.mockReturnValueOnce('1'); - const wrapper = mount(); - await waitForComponentToPaint(wrapper); - const callsD = fetchMock.calls(/database\/\?q/); - expect(callsD).toHaveLength(0); + // expect(await screen.findByText(/Settings/i)).toBeInTheDocument(); + await waitFor(() => expect(container).toBeInTheDocument()); +}); + +test('If user has permission to upload files AND connect DBs we query existing DBs that has allow_file_upload as True and DBs that are not examples', async () => { + const mockedProps = createProps(); + // Initial Load + resetUseSelectorMock(); + const { container } = render(, { + useRedux: true, + useQueryParams: true, }); - it('If user has permission to upload files but there are only gsheets and clickhouse DBs', async () => { - fetchMock.get( - 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', - { result: [...mockGsheetsDbs], database_count: 2 }, - { overwriteRoutes: true }, - ); - useSelectorMock.mockReturnValueOnce({ - createdOn: '2021-04-27T18:12:38.952304', - email: 'admin', - firstName: 'admin', - isActive: true, - lastName: 'admin', - permissions: {}, - roles: { - Admin: [ - ['can_this_form_get', 'CsvToDatabaseView'], // So we can upload CSV - ], - }, - userId: 1, - username: 'admin', - }); - // Second call we get the dashboardId - useSelectorMock.mockReturnValueOnce('1'); - const wrapper = mount(); - await waitForComponentToPaint(wrapper); - const callsD = fetchMock.calls(/database\/\?q/); - expect(callsD).toHaveLength(1); - expect(setAllowUploads).toHaveBeenCalledWith(false); + await waitFor(() => expect(container).toBeVisible()); + const callsD = fetchMock.calls(/database\/\?q/); + expect(callsD).toHaveLength(2); + expect(callsD[0][0]).toMatchInlineSnapshot( + `"http://localhost/api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))"`, + ); + expect(callsD[1][0]).toMatchInlineSnapshot( + `"http://localhost/api/v1/database/?q=(filters:!((col:database_name,opr:neq,value:examples)))"`, + ); +}); + +test('If only examples DB exist we must show the Connect Database option', async () => { + const mockedProps = createProps(); + fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + { result: [...mockNonExamplesDB], count: 2 }, + { overwriteRoutes: true }, + ); + fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:database_name,opr:neq,value:examples)))', + { result: [], count: 0 }, + { overwriteRoutes: true }, + ); + // Initial Load + resetUseSelectorMock(); + // setAllowUploads called + resetUseSelectorMock(); + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, }); - it('If user has permission to upload files and some DBs with allow_file_upload are not gsheets nor clickhouse', async () => { - fetchMock.get( - 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', - { result: [...mockNonGSheetsDBs, ...mockGsheetsDbs], database_count: 2 }, - { overwriteRoutes: true }, - ); - useSelectorMock.mockReturnValueOnce({ - createdOn: '2021-04-27T18:12:38.952304', - email: 'admin', - firstName: 'admin', - isActive: true, - lastName: 'admin', - permissions: {}, - roles: { - Admin: [ - ['can_this_form_get', 'CsvToDatabaseView'], // So we can upload CSV - ], - }, - userId: 1, - username: 'admin', - }); - // Second call we get the dashboardId - useSelectorMock.mockReturnValueOnce('1'); - const wrapper = mount(); - await waitForComponentToPaint(wrapper); - const callsD = fetchMock.calls(/database\/\?q/); - expect(callsD).toHaveLength(1); - expect(setAllowUploads).toHaveBeenCalledWith(true); + const dropdown = screen.getByTestId('new-dropdown-icon'); + userEvent.hover(dropdown); + const dataMenu = await screen.findByText(dropdownItems[0].label); + userEvent.hover(dataMenu); + expect(await screen.findByText('Connect database')).toBeInTheDocument(); + expect(screen.queryByText('Create dataset')).not.toBeInTheDocument(); +}); + +test('If more than just examples DB exist we must show the Create dataset option', async () => { + const mockedProps = createProps(); + fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + { result: [...mockNonExamplesDB], count: 2 }, + { overwriteRoutes: true }, + ); + fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:database_name,opr:neq,value:examples)))', + { result: [...mockNonExamplesDB], count: 2 }, + { overwriteRoutes: true }, + ); + // Initial Load + resetUseSelectorMock(); + // setAllowUploads called + resetUseSelectorMock(); + // setNonExamplesDBConnected called + resetUseSelectorMock(); + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + }); + const dropdown = screen.getByTestId('new-dropdown-icon'); + userEvent.hover(dropdown); + const dataMenu = await screen.findByText(dropdownItems[0].label); + userEvent.hover(dataMenu); + expect(await screen.findByText('Create dataset')).toBeInTheDocument(); + expect(screen.queryByText('Connect database')).not.toBeInTheDocument(); +}); + +test('If there is a DB with allow_file_upload set as True the option should be enabled', async () => { + const mockedProps = createProps(); + fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + { result: [...mockNonExamplesDB], count: 2 }, + { overwriteRoutes: true }, + ); + fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:database_name,opr:neq,value:examples)))', + { result: [...mockNonExamplesDB], count: 2 }, + { overwriteRoutes: true }, + ); + // Initial load + resetUseSelectorMock(); + // setAllowUploads called + resetUseSelectorMock(); + // setNonExamplesDBConnected called + resetUseSelectorMock(); + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + }); + const dropdown = screen.getByTestId('new-dropdown-icon'); + userEvent.hover(dropdown); + const dataMenu = await screen.findByText(dropdownItems[0].label); + userEvent.hover(dataMenu); + expect( + (await screen.findByText('Upload CSV to database')).closest('a'), + ).toHaveAttribute('href', '/csvtodatabaseview/form'); +}); + +test('If there is NOT a DB with allow_file_upload set as True the option should be disabled', async () => { + const mockedProps = createProps(); + fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + { result: [], count: 0 }, + { overwriteRoutes: true }, + ); + fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:database_name,opr:neq,value:examples)))', + { result: [...mockNonExamplesDB], count: 2 }, + { overwriteRoutes: true }, + ); + // Initial load + resetUseSelectorMock(); + // setAllowUploads called + resetUseSelectorMock(); + // setNonExamplesDBConnected called + resetUseSelectorMock(); + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, }); + const dropdown = screen.getByTestId('new-dropdown-icon'); + userEvent.hover(dropdown); + const dataMenu = await screen.findByText(dropdownItems[0].label); + userEvent.hover(dataMenu); + expect(await screen.findByText('Upload CSV to database')).toBeInTheDocument(); + expect( + (await screen.findByText('Upload CSV to database')).closest('a'), + ).not.toBeInTheDocument(); }); diff --git a/superset-frontend/src/views/components/RightMenu.tsx b/superset-frontend/src/views/components/RightMenu.tsx index 5563c787afc61..839f3cf4ae83a 100644 --- a/superset-frontend/src/views/components/RightMenu.tsx +++ b/superset-frontend/src/views/components/RightMenu.tsx @@ -16,11 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import React, { Fragment, useEffect } from 'react'; +import React, { Fragment, useState, useEffect } from 'react'; import rison from 'rison'; import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import { useQueryParams, BooleanParam } from 'use-query-params'; +import { isEmpty } from 'lodash'; import { t, @@ -48,6 +49,7 @@ import { RightMenuProps, } from './types'; import { MenuObjectProps } from './Menu'; +import AddDatasetModal from '../CRUD/data/dataset/AddDatasetModal'; const extensionsRegistry = getExtensionsRegistry(); @@ -91,6 +93,13 @@ const tagStyles = (theme: SupersetTheme) => css` color: ${theme.colors.grayscale.light5}; `; +const styledChildMenu = (theme: SupersetTheme) => css` + &:hover { + color: ${theme.colors.primary.base} !important; + cursor: pointer !important; + } +`; + const { SubMenu } = Menu; const RightMenu = ({ @@ -101,7 +110,13 @@ const RightMenu = ({ environmentTag, setQuery, }: RightMenuProps & { - setQuery: ({ databaseAdded }: { databaseAdded: boolean }) => void; + setQuery: ({ + databaseAdded, + datasetAdded, + }: { + databaseAdded?: boolean; + datasetAdded?: boolean; + }) => void; }) => { const user = useSelector( state => state.user, @@ -118,12 +133,14 @@ const RightMenu = ({ ALLOWED_EXTENSIONS, HAS_GSHEETS_INSTALLED, } = useSelector(state => state.common.conf); - const [showModal, setShowModal] = React.useState(false); - const [engine, setEngine] = React.useState(''); + const [showDatabaseModal, setShowDatabaseModal] = useState(false); + const [showDatasetModal, setShowDatasetModal] = useState(false); + const [engine, setEngine] = useState(''); const canSql = findPermission('can_sqllab', 'Superset', roles); const canDashboard = findPermission('can_write', 'Dashboard', roles); const canChart = findPermission('can_write', 'Chart', roles); const canDatabase = findPermission('can_write', 'Database', roles); + const canDataset = findPermission('can_write', 'Dataset', roles); const { canUploadData, canUploadCSV, canUploadColumnar, canUploadExcel } = uploadUserPerms( @@ -135,7 +152,9 @@ const RightMenu = ({ ); const showActionDropdown = canSql || canChart || canDashboard; - const [allowUploads, setAllowUploads] = React.useState(false); + const [allowUploads, setAllowUploads] = useState(false); + const [nonExamplesDBConnected, setNonExamplesDBConnected] = + useState(false); const isAdmin = isUserAdmin(user); const showUploads = allowUploads || isAdmin; const dropdownItems: MenuObjectProps[] = [ @@ -146,7 +165,12 @@ const RightMenu = ({ { label: t('Connect database'), name: GlobalMenuDataOptions.DB_CONNECTION, - perm: canDatabase, + perm: canDatabase && !nonExamplesDBConnected, + }, + { + label: t('Create dataset'), + name: GlobalMenuDataOptions.DATASET_CREATION, + perm: canDataset && nonExamplesDBConnected, }, { label: t('Connect Google Sheet'), @@ -217,12 +241,29 @@ const RightMenu = ({ }); }; + const existsNonExamplesDatabases = () => { + const payload = { + filters: [{ col: 'database_name', opr: 'neq', value: 'examples' }], + }; + SupersetClient.get({ + endpoint: `/api/v1/database/?q=${rison.encode(payload)}`, + }).then(({ json }: Record) => { + setNonExamplesDBConnected(json.count >= 1); + }); + }; + useEffect(() => { if (canUploadData) { checkAllowUploads(); } }, [canUploadData]); + useEffect(() => { + if (canDatabase || canDataset) { + existsNonExamplesDatabases(); + } + }, [canDatabase, canDataset]); + const menuIconAndLabel = (menu: MenuObjectProps) => ( <> @@ -232,16 +273,22 @@ const RightMenu = ({ const handleMenuSelection = (itemChose: any) => { if (itemChose.key === GlobalMenuDataOptions.DB_CONNECTION) { - setShowModal(true); + setShowDatabaseModal(true); } else if (itemChose.key === GlobalMenuDataOptions.GOOGLE_SHEETS) { - setShowModal(true); + setShowDatabaseModal(true); setEngine('Google Sheets'); + } else if (itemChose.key === GlobalMenuDataOptions.DATASET_CREATION) { + setShowDatasetModal(true); } }; const handleOnHideModal = () => { setEngine(''); - setShowModal(false); + setShowDatabaseModal(false); + }; + + const handleOnHideDatasetModalModal = () => { + setShowDatasetModal(false); }; const isDisabled = isAdmin && !allowUploads; @@ -259,21 +306,33 @@ const RightMenu = ({ ) : ( - + {item.url ? {item.label} : item.label} ); }; const onMenuOpen = (openKeys: string[]) => { - if (openKeys.length && canUploadData) { - return checkAllowUploads(); + // We should query the API only if opening Data submenus + // because the rest don't need this information. Not using + // "Data" directly since we might change the label later on? + if ( + openKeys.length > 1 && + !isEmpty( + openKeys?.filter((key: string) => + key.includes(`sub2_${dropdownItems?.[0]?.label}`), + ), + ) + ) { + if (canUploadData) checkAllowUploads(); + if (canDatabase || canDataset) existsNonExamplesDatabases(); } return null; }; const RightMenuExtension = extensionsRegistry.get('navbar.right'); const handleDatabaseAdd = () => setQuery({ databaseAdded: true }); + const handleDatasetAdd = () => setQuery({ datasetAdded: true }); const theme = useTheme(); @@ -282,11 +341,18 @@ const RightMenu = ({ {canDatabase && ( )} + {canDataset && ( + + )} {environmentTag?.text && (