/** * 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 type React from 'react'; import { Route } from 'react-router-dom'; import fetchMock from 'fetch-mock'; import { DatasourceType, JsonObject, SupersetClient } from '@superset-ui/core'; import { render, screen, userEvent, waitFor, } from 'spec/helpers/testing-library'; import { fallbackExploreInitialData } from 'src/explore/fixtures'; import type { ColumnObject } from 'src/features/datasets/types'; import DatasourceControl from '.'; // Mock DatasourceEditor to avoid mounting the full 2,500+ line editor tree. // The heavy editor (CollectionTable, FilterableTable, DatabaseSelector, etc.) // causes OOM in CI when rendered repeatedly. These tests only need to verify // DatasourceControl's callback wiring through the modal save flow. // Editor internals are tested in DatasourceEditor.test.tsx. jest.mock('src/components/Datasource/components/DatasourceEditor', () => ({ __esModule: true, default: () => require('react').createElement( 'div', { 'data-test': 'mock-datasource-editor' }, 'Mock Editor', ), })); afterEach(() => { jest.restoreAllMocks(); try { const unmatched = fetchMock.callHistory.calls('unmatched'); if (unmatched.length > 0) { const urls = unmatched.map(call => call.url).join(', '); throw new Error( `fetchMock: ${unmatched.length} unmatched call(s): ${urls}`, ); } } finally { fetchMock.clearHistory().removeRoutes(); jest.restoreAllMocks(); } }); interface TestDatasource { id?: number; name: string; datasource_name?: string; database: { id: number; database_name: string; name?: string; backend?: string; }; columns?: Partial[]; type?: DatasourceType; main_dttm_col?: string | null; owners?: Array<{ first_name: string; last_name: string; id: number; username?: string; }>; sql?: string; metrics?: Array<{ id: number; metric_name: string }>; [key: string]: unknown; } const mockDatasource: TestDatasource = { id: 25, database: { id: 1, database_name: 'examples', name: 'examples', }, name: 'channels', datasource_name: 'channels', type: DatasourceType.Table, columns: [], owners: [{ first_name: 'john', last_name: 'doe', id: 1, username: 'jd' }], sql: 'SELECT * FROM mock_datasource_sql', }; // Use type assertion for test props since the component is wrapped with withTheme // The withTheme HOC makes the props type complex, so we cast through unknown to bypass type check type DatasourceControlComponentProps = React.ComponentProps< typeof DatasourceControl >; const createProps = ( overrides: JsonObject = {}, ): DatasourceControlComponentProps => ({ hovered: false, type: 'DatasourceControl', label: 'Datasource', default: null, description: null, value: '25__table', form_data: {}, datasource: mockDatasource, validationErrors: [], name: 'datasource', actions: { changeDatasource: jest.fn(), setControlValue: jest.fn(), }, isEditable: true, user: { createdOn: '2021-04-27T18:12:38.952304', email: 'admin', firstName: 'admin', isActive: true, lastName: 'admin', permissions: {}, roles: { Admin: Array(173) }, userId: 1, username: 'admin', }, onChange: jest.fn(), onDatasourceSave: jest.fn(), ...overrides, }) as unknown as DatasourceControlComponentProps; const getDbWithQuery = 'glob:*/api/v1/database/?q=*'; const getDatasetWithAll = 'glob:*/api/v1/dataset/*'; const putDatasetWithAll = 'glob:*/api/v1/dataset/*'; const getDatasetWithAllMockRouteName = `get${getDatasetWithAll}`; const putDatasetWithAllMockRouteName = `put${putDatasetWithAll}`; async function openAndSaveChanges( datasource: TestDatasource | Record, ) { fetchMock.removeRoute(getDbWithQuery); fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery }); fetchMock.removeRoute(putDatasetWithAllMockRouteName); fetchMock.put( putDatasetWithAll, {}, { name: putDatasetWithAllMockRouteName }, ); fetchMock.removeRoute(getDatasetWithAllMockRouteName); fetchMock.get( getDatasetWithAll, { result: datasource }, { name: getDatasetWithAllMockRouteName, }, ); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); await userEvent.click(await screen.findByTestId('edit-dataset')); await userEvent.click(await screen.findByTestId('datasource-modal-save')); await userEvent.click(await screen.findByText('OK')); } test('Should render', async () => { const props = createProps(); render(, { useRouter: true }); expect(await screen.findByTestId('datasource-control')).toBeVisible(); }); test('Should have elements', async () => { const props = createProps(); render(, { useRouter: true }); expect(await screen.findByText('channels')).toBeVisible(); expect(screen.getByTestId('datasource-menu-trigger')).toBeVisible(); }); test('Should open a menu', async () => { const props = createProps(); render(, { useRouter: true }); expect(screen.queryByText('Edit dataset')).not.toBeInTheDocument(); expect(screen.queryByText('Swap dataset')).not.toBeInTheDocument(); expect(screen.queryByText('View in SQL Lab')).not.toBeInTheDocument(); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); expect(await screen.findByText('Edit dataset')).toBeInTheDocument(); expect(screen.getByText('Swap dataset')).toBeInTheDocument(); expect(screen.getByText('View in SQL Lab')).toBeInTheDocument(); }); test('Should not show SQL Lab for non sql_lab role', async () => { const props = createProps({ user: { createdOn: '2021-04-27T18:12:38.952304', email: 'gamma', firstName: 'gamma', isActive: true, lastName: 'gamma', permissions: {}, roles: { Gamma: [] }, userId: 2, username: 'gamma', }, }); render(, { useRouter: true }); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); expect(await screen.findByText('Edit dataset')).toBeInTheDocument(); expect(screen.getByText('Swap dataset')).toBeInTheDocument(); expect(screen.queryByText('View in SQL Lab')).not.toBeInTheDocument(); }); test('Should show SQL Lab for sql_lab role', async () => { const props = createProps({ user: { createdOn: '2021-04-27T18:12:38.952304', email: 'sql', firstName: 'sql', isActive: true, lastName: 'sql', permissions: {}, roles: { Gamma: [], sql_lab: [['menu_access', 'SQL Lab']] }, userId: 2, username: 'sql', }, }); render(, { useRouter: true }); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); expect(await screen.findByText('Edit dataset')).toBeInTheDocument(); expect(screen.getByText('Swap dataset')).toBeInTheDocument(); expect(screen.getByText('View in SQL Lab')).toBeInTheDocument(); }); test('Click on Swap dataset option', async () => { const props = createProps(); jest .spyOn(SupersetClient, 'get') .mockImplementation(async ({ endpoint }: { endpoint: string }) => { if (endpoint.includes('_info')) { return { json: { permissions: ['can_read', 'can_write'] }, } as any; } return { json: { result: [] } } as any; }); render(, { useRedux: true, useRouter: true, }); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); await userEvent.click(screen.getByText('Swap dataset')); expect( screen.getByText( 'Changing the dataset may break the chart if the chart relies on columns or metadata that does not exist in the target dataset', ), ).toBeInTheDocument(); }); test('Click on Edit dataset', async () => { const props = createProps(); fetchMock.removeRoute(getDbWithQuery); fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery }); render(, { useRedux: true, useRouter: true, }); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); await userEvent.click(screen.getByText('Edit dataset')); expect( await screen.findByTestId('mock-datasource-editor'), ).toBeInTheDocument(); }); test('Edit dataset should be disabled when user is not admin', async () => { const props = createProps(); props.user.roles = {}; props.datasource.owners = []; render(, { useRedux: true, useRouter: true, }); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); expect(await screen.findByTestId('edit-dataset')).toHaveAttribute( 'aria-disabled', 'true', ); }); test('Click on View in SQL Lab', async () => { const props = createProps(); const { queryByTestId, getByTestId } = render( <> (
{JSON.stringify(location.state)}
)} /> , { useRedux: true, useRouter: true, }, ); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); expect(queryByTestId('mock-sqllab-route')).not.toBeInTheDocument(); await userEvent.click(screen.getByText('View in SQL Lab')); expect(getByTestId('mock-sqllab-route')).toBeInTheDocument(); expect(JSON.parse(`${getByTestId('mock-sqllab-route').textContent}`)).toEqual( { requestedQuery: { datasourceKey: `${mockDatasource.id}__${mockDatasource.type}`, sql: mockDatasource.sql, }, }, ); }); test('Should open a different menu when datasource=query', async () => { const props = createProps(); const queryProps = { ...props, datasource: { ...props.datasource, type: DatasourceType.Query, }, }; render(, { useRouter: true }); expect(screen.queryByText('Query preview')).not.toBeInTheDocument(); expect(screen.queryByText('View in SQL Lab')).not.toBeInTheDocument(); expect(screen.queryByText('Save as dataset')).not.toBeInTheDocument(); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); expect(await screen.findByText('Query preview')).toBeInTheDocument(); expect(screen.getByText('View in SQL Lab')).toBeInTheDocument(); expect(screen.getByText('Save as dataset')).toBeInTheDocument(); }); test('Click on Save as dataset', async () => { const props = createProps(); const queryProps = { ...props, datasource: { ...props.datasource, type: DatasourceType.Query, }, }; render(, { useRedux: true, useRouter: true, }); await userEvent.click(screen.getByTestId('datasource-menu-trigger')); expect( screen.queryByRole('button', { name: /save/i }), ).not.toBeInTheDocument(); expect( screen.queryByRole('button', { name: /close/i }), ).not.toBeInTheDocument(); expect( screen.queryByText(/select or type dataset name/i), ).not.toBeInTheDocument(); await userEvent.click(screen.getByText('Save as dataset')); // Renders a save dataset modal const saveRadioBtn = await screen.findByRole('radio', { name: /save as new/i, }); const overwriteRadioBtn = screen.getByRole('radio', { name: /overwrite existing/i, }); const dropdownField = screen.getByText(/select or type dataset name/i); expect(saveRadioBtn).toBeInTheDocument(); expect(overwriteRadioBtn).toBeInTheDocument(); expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); expect(dropdownField).toBeInTheDocument(); }); test('should set the default temporal column', async () => { const props = createProps(); const overrideProps = { ...props, form_data: { granularity_sqla: 'test-col', }, datasource: { ...props.datasource, main_dttm_col: 'test-default', columns: [ { column_name: 'test-col', is_dttm: false, }, { column_name: 'test-default', is_dttm: true, }, ], }, }; render(, { useRedux: true, useRouter: true, }); await openAndSaveChanges(overrideProps.datasource); await waitFor(() => { expect(props.actions.setControlValue).toHaveBeenCalledWith( 'granularity_sqla', 'test-default', ); }); }); test('should set the first available temporal column', async () => { const props = createProps(); const overrideProps = { ...props, form_data: { granularity_sqla: 'test-col', }, datasource: { ...props.datasource, main_dttm_col: null, columns: [ { column_name: 'test-col', is_dttm: false, }, { column_name: 'test-first', is_dttm: true, }, ], }, }; render(, { useRedux: true, useRouter: true, }); await openAndSaveChanges(overrideProps.datasource); await waitFor(() => { expect(props.actions.setControlValue).toHaveBeenCalledWith( 'granularity_sqla', 'test-first', ); }); }); test('should not set the temporal column', async () => { const props = createProps(); const overrideProps = { ...props, form_data: { granularity_sqla: undefined, }, datasource: { ...props.datasource, main_dttm_col: undefined, columns: [ { column_name: 'test-col', is_dttm: false, }, { column_name: 'test-col-2', is_dttm: false, }, ], }, }; render(, { useRedux: true, useRouter: true, }); await openAndSaveChanges(overrideProps.datasource); await waitFor(() => { expect(props.actions.setControlValue).toHaveBeenCalledWith( 'granularity_sqla', null, ); }); }); test('should show missing params state', () => { const props = createProps({ datasource: fallbackExploreInitialData.dataset }); render(, { useRedux: true, useRouter: true }); expect(screen.getByText(/missing dataset/i)).toBeVisible(); expect(screen.getByText(/missing url parameters/i)).toBeVisible(); expect( screen.getByText( /the url is missing the dataset_id or slice_id parameters/i, ), ).toBeVisible(); }); test('should show missing dataset state', () => { jest.spyOn(window, 'location', 'get').mockReturnValue({ ...window.location, search: '?slice_id=152', } as Location); const props = createProps({ datasource: fallbackExploreInitialData.dataset }); render(, { useRedux: true, useRouter: true }); expect(screen.getAllByText(/missing dataset/i)).toHaveLength(2); expect( screen.getByText( /the dataset linked to this chart may have been deleted\./i, ), ).toBeVisible(); }); test('should show forbidden dataset state', () => { jest.spyOn(window, 'location', 'get').mockReturnValue({ ...window.location, search: '?slice_id=152', } as Location); const error = { error_type: 'TABLE_SECURITY_ACCESS_ERROR', statusText: 'FORBIDDEN', message: 'You do not have access to the following tables: blocked_table', extra: { datasource: 152, datasource_name: 'forbidden dataset', }, }; const props = createProps({ datasource: { ...fallbackExploreInitialData.dataset, extra: { error, }, }, }); render(, { useRedux: true, useRouter: true }); expect(screen.getByText(error.message)).toBeInTheDocument(); expect(screen.getByText(error.statusText)).toBeVisible(); }); test('should fire onDatasourceSave when saving with new metrics', async () => { const props = createProps({ datasource: { ...mockDatasource, metrics: [] }, }); render(, { useRedux: true, useRouter: true, }); await openAndSaveChanges({ ...mockDatasource, metrics: [{ id: 1, metric_name: 'test_metric' }], }); await waitFor(() => { expect(props.onDatasourceSave).toHaveBeenCalledWith( expect.objectContaining({ metrics: [{ id: 1, metric_name: 'test_metric' }], }), ); }); }); test('should fire onDatasourceSave when saving with removed metrics', async () => { const props = createProps({ datasource: { ...mockDatasource, metrics: [{ id: 1, metric_name: 'existing_metric' }], }, }); render(, { useRedux: true, useRouter: true, }); await openAndSaveChanges({ ...mockDatasource, metrics: [] }); await waitFor(() => { expect(props.onDatasourceSave).toHaveBeenCalledWith( expect.objectContaining({ metrics: [] }), ); }); }); test('should handle metric save confirmation modal', async () => { const props = createProps(); render(, { useRedux: true, useRouter: true, }); await openAndSaveChanges(mockDatasource); await waitFor(() => { expect(props.onDatasourceSave).toHaveBeenCalled(); }); }); test('should fire onDatasourceSave callback on save', async () => { const mockOnDatasourceSave = jest.fn(); const props = createProps({ datasource: mockDatasource, onDatasourceSave: mockOnDatasourceSave, }); render(, { useRedux: true, useRouter: true, }); await openAndSaveChanges(mockDatasource); await waitFor(() => { expect(mockOnDatasourceSave).toHaveBeenCalledWith( expect.objectContaining({ id: expect.any(Number), name: expect.any(String), }), ); }); });