diff --git a/superset-frontend/src/dashboard/components/gridComponents/Header.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Header.test.jsx index 59708224041..e015f161b37 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Header.test.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Header.test.jsx @@ -17,16 +17,11 @@ * under the License. */ import { Provider } from 'react-redux'; -import { styledMount as mount } from 'spec/helpers/theming'; -import sinon from 'sinon'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; +import sinon from 'sinon'; -import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton'; -import EditableTitle from 'src/components/EditableTitle'; -import HoverMenu from 'src/dashboard/components/menu/HoverMenu'; -import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu'; -import { Draggable } from 'src/dashboard/components/dnd/DragDroppable'; +import { render, screen, fireEvent } from 'spec/helpers/testing-library'; import Header from 'src/dashboard/components/gridComponents/Header'; import newComponentFactory from 'src/dashboard/util/newComponentFactory'; import { @@ -53,49 +48,53 @@ describe('Header', () => { }; function setup(overrideProps) { - // We have to wrap provide DragDropContext for the underlying DragDroppable - // otherwise we cannot assert on DragDroppable children - const wrapper = mount( + return render(
, ); - return wrapper; } it('should render a Draggable', () => { - const wrapper = setup(); - expect(wrapper.find(Draggable)).toBeTruthy(); + setup(); + expect(screen.getByTestId('dragdroppable-object')).toBeInTheDocument(); }); it('should render a WithPopoverMenu', () => { - const wrapper = setup(); - expect(wrapper.find(WithPopoverMenu)).toBeTruthy(); + setup(); + expect(screen.getByRole('none')).toBeInTheDocument(); }); it('should render a HoverMenu in editMode', () => { - let wrapper = setup(); - expect(wrapper.find(HoverMenu).length).toBe(0); + setup(); + expect(screen.queryByTestId('hover-menu')).not.toBeInTheDocument(); - // we cannot set props on the Header because of the WithDragDropContext wrapper - wrapper = setup({ editMode: true }); - expect(wrapper.find(HoverMenu).length).toBeGreaterThan(0); + setup({ editMode: true }); + const hoverMenus = screen.getAllByTestId('hover-menu'); + expect(hoverMenus[0]).toBeInTheDocument(); }); it('should render an EditableTitle with meta.text', () => { - const wrapper = setup(); - expect(wrapper.find(EditableTitle)).toBeTruthy(); - expect(wrapper.find('.editable-title').text()).toBe( - props.component.meta.text, - ); + setup(); + const titleElement = screen.getByTestId('editable-title-input'); + expect(titleElement).toBeInTheDocument(); + expect(titleElement).toHaveTextContent(props.component.meta.text); }); it('should call updateComponents when EditableTitle changes', () => { const updateComponents = sinon.spy(); - const wrapper = setup({ editMode: true, updateComponents }); - wrapper.find(EditableTitle).prop('onSaveTitle')('New title'); + setup({ editMode: true, updateComponents }); + + // First click to enter edit mode + const titleButton = screen.getByTestId('editable-title-input'); + fireEvent.click(titleButton); + + // Then change the input value and blur to trigger save + const titleInput = screen.getByTestId('editable-title-input'); + fireEvent.change(titleInput, { target: { value: 'New title' } }); + fireEvent.blur(titleInput); const headerId = props.component.id; expect(updateComponents.callCount).toBe(1); @@ -105,33 +104,33 @@ describe('Header', () => { }); it('should render a DeleteComponentButton when focused in editMode', () => { - const wrapper = setup({ editMode: true }); - wrapper.find(WithPopoverMenu).simulate('click'); // focus - - expect(wrapper.find(DeleteComponentButton)).toBeTruthy(); + setup({ editMode: true }); + const trashButton = screen.getByRole('img', { name: 'trash' }); + expect(trashButton).toBeInTheDocument(); }); it('should call deleteComponent when deleted', () => { const deleteComponent = sinon.spy(); - const wrapper = setup({ editMode: true, deleteComponent }); - wrapper.find(WithPopoverMenu).simulate('click'); // focus - wrapper.find(DeleteComponentButton).simulate('click'); + setup({ editMode: true, deleteComponent }); + + const trashButton = screen.getByRole('img', { name: 'trash' }); + fireEvent.click(trashButton.parentElement); expect(deleteComponent.callCount).toBe(1); }); it('should render the AnchorLink in view mode', () => { - const wrapper = setup(); - expect(wrapper.find('AnchorLink')).toBeTruthy(); + setup(); + expect(screen.getByTestId('anchor-link')).toBeInTheDocument(); }); it('should not render the AnchorLink in edit mode', () => { - const wrapper = setup({ editMode: true }); - expect(wrapper.find('AnchorLink').length).toBe(0); + setup({ editMode: true }); + expect(screen.queryByTestId('anchor-link')).not.toBeInTheDocument(); }); it('should not render the AnchorLink in embedded mode', () => { - const wrapper = setup({ embeddedMode: true }); - expect(wrapper.find('AnchorLink').length).toBe(0); + setup({ embeddedMode: true }); + expect(screen.queryByTestId('anchor-link')).not.toBeInTheDocument(); }); }); diff --git a/superset-frontend/src/pages/AlertReportList/AlertReportList.test.jsx b/superset-frontend/src/pages/AlertReportList/AlertReportList.test.jsx index 87ea8892834..ffa5717279a 100644 --- a/superset-frontend/src/pages/AlertReportList/AlertReportList.test.jsx +++ b/superset-frontend/src/pages/AlertReportList/AlertReportList.test.jsx @@ -18,18 +18,17 @@ */ import fetchMock from 'fetch-mock'; import configureStore from 'redux-mock-store'; -import { Provider } from 'react-redux'; import thunk from 'redux-thunk'; -import { styledMount as mount } from 'spec/helpers/theming'; -import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; -import { Switch } from 'src/components/Switch'; -import ListView from 'src/components/ListView'; -import SubMenu from 'src/features/home/SubMenu'; +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; import AlertList from 'src/pages/AlertReportList'; -import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; -import { act } from 'spec/helpers/testing-library'; -// store needed for withToasts(AlertList) const mockStore = configureStore([thunk]); const store = mockStore({}); @@ -84,102 +83,153 @@ fetchMock.put(alertsEndpoint, { ...mockalerts[0], active: false }); fetchMock.delete(alertEndpoint, {}); fetchMock.delete(alertsEndpoint, {}); -async function mountAndWait(props = {}) { - const mounted = mount( - - - , +const renderAlertList = (props = {}) => + render( + + + + + , + { + useRedux: true, + store, + }, ); - await waitForComponentToPaint(mounted); - - return mounted; -} - describe('AlertList', () => { - let wrapper; - - beforeAll(async () => { - wrapper = await mountAndWait(); + beforeEach(() => { + fetchMock.resetHistory(); }); it('renders', async () => { - expect(wrapper.find(AlertList)).toBeTruthy(); + renderAlertList(); + expect(await screen.findByText('Alerts & reports')).toBeInTheDocument(); }); it('renders a SubMenu', async () => { - expect(wrapper.find(SubMenu)).toBeTruthy(); + renderAlertList(); + expect(await screen.findByRole('navigation')).toBeInTheDocument(); }); it('renders a ListView', async () => { - expect(wrapper.find(ListView)).toBeTruthy(); + renderAlertList(); + expect(await screen.findByTestId('alerts-list-view')).toBeInTheDocument(); }); it('renders switches', async () => { - expect(wrapper.find(Switch)).toHaveLength(3); + renderAlertList(); + // Wait for the list to load first + await screen.findByTestId('alerts-list-view'); + const switches = await screen.findAllByRole('switch'); + expect(switches).toHaveLength(3); }); it('deletes', async () => { - act(() => { - wrapper.find('[data-test="delete-action"]').first().props().onClick(); - }); - await waitForComponentToPaint(wrapper); + renderAlertList(); - act(() => { - wrapper - .find('#delete') - .first() - .props() - .onChange({ target: { value: 'DELETE' } }); - }); - await waitForComponentToPaint(wrapper); - act(() => { - wrapper - .find('[data-test="modal-confirm-button"]') - .last() - .props() - .onClick(); - }); + // Wait for list to load + await screen.findByTestId('alerts-list-view'); - await waitForComponentToPaint(wrapper); + // Find and click first delete button + const deleteButtons = await screen.findAllByTestId('delete-action'); + fireEvent.click(deleteButtons[0]); - expect(fetchMock.calls(/report\/0/, 'DELETE')).toHaveLength(1); - }); + // Wait for modal to appear and find the delete input + const deleteInput = await screen.findByTestId('delete-modal-input'); + fireEvent.change(deleteInput, { target: { value: 'DELETE' } }); + + // Click confirm button + const confirmButton = await screen.findByTestId('modal-confirm-button'); + fireEvent.click(confirmButton); + + // Wait for delete request + await waitFor(() => { + expect(fetchMock.calls(/report\/0/, 'DELETE')).toHaveLength(1); + }); + }, 15000); it('shows/hides bulk actions when bulk actions is clicked', async () => { - const button = wrapper.find('[data-test="bulk-select-toggle"]').first(); - act(() => { - button.props().onClick(); - }); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(IndeterminateCheckbox)).toHaveLength( - mockalerts.length + 1, // 1 for each row and 1 for select all - ); - }); + renderAlertList(); + + // Wait for list to load and initial state + await screen.findByTestId('alerts-list-view'); + expect( + screen.queryByTestId('bulk-select-controls'), + ).not.toBeInTheDocument(); + + // Click bulk select toggle + const bulkSelectButton = await screen.findByTestId('bulk-select-toggle'); + fireEvent.click(bulkSelectButton); + + // Verify bulk select controls appear + expect( + await screen.findByTestId('bulk-select-controls'), + ).toBeInTheDocument(); + }, 15000); it('hides bulk actions when switch between alert and report list', async () => { - expect(wrapper.find(IndeterminateCheckbox)).toHaveLength( - mockalerts.length + 1, - ); - expect(wrapper.find('[data-test="alert-list"]').hasClass('active')).toBe( - true, - ); - expect(wrapper.find('[data-test="report-list"]').hasClass('active')).toBe( - false, - ); + // Start with alert list + renderAlertList(); - const reportWrapper = await mountAndWait({ isReportEnabled: true }); + // Wait for list to load + await screen.findByTestId('alerts-list-view'); - expect(fetchMock.calls(/report\/\?q/)[2][0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/report/?q=(filters:!((col:type,opr:eq,value:Report)),order_column:name,order_direction:desc,page:0,page_size:25)"`, - ); + // Click bulk select to show controls + const bulkSelectButton = await screen.findByTestId('bulk-select-toggle'); + fireEvent.click(bulkSelectButton); + // Verify bulk select controls appear expect( - reportWrapper.find('[data-test="report-list"]').hasClass('active'), - ).toBe(true); - expect( - reportWrapper.find('[data-test="alert-list"]').hasClass('active'), - ).toBe(false); - expect(reportWrapper.find(IndeterminateCheckbox)).toHaveLength(0); - }); + await screen.findByTestId('bulk-select-controls'), + ).toBeInTheDocument(); + + // Verify alert tab is active + const alertTab = await screen.findByTestId('alert-list'); + expect(alertTab).toHaveClass('active'); + const reportTab = screen.getByTestId('report-list'); + expect(reportTab).not.toHaveClass('active'); + + // Switch to report list + renderAlertList({ isReportEnabled: true }); + + // Wait for report list API call and tab states to update + await waitFor(async () => { + // Check API call + const calls = fetchMock.calls(/report\/\?q/); + const hasReportCall = calls.some(call => + call[0].includes('filters:!((col:type,opr:eq,value:Report))'), + ); + + // Check tab states + const reportTabs = screen.getAllByTestId('report-list'); + const alertTabs = screen.getAllByTestId('alert-list'); + const hasActiveReport = reportTabs.some(tab => + tab.classList.contains('active'), + ); + const hasNoActiveAlert = alertTabs.every( + tab => !tab.classList.contains('active'), + ); + + return hasReportCall && hasActiveReport && hasNoActiveAlert; + }); + + // Click bulk select toggle again to hide controls + const bulkSelectButtons = + await screen.findAllByTestId('bulk-select-toggle'); + fireEvent.click(bulkSelectButtons[0]); + + // Verify final state + await waitFor(() => { + expect( + screen.queryByTestId('bulk-select-controls'), + ).not.toBeInTheDocument(); + }); + + // Verify correct API call was made + const reportCalls = fetchMock.calls(/report\/\?q/); + const lastReportCall = reportCalls[reportCalls.length - 1][0]; + expect(lastReportCall).toContain( + 'filters:!((col:type,opr:eq,value:Report))', + ); + }, 15000); }); diff --git a/superset-frontend/src/pages/AnnotationLayerList/AnnotationLayerList.test.jsx b/superset-frontend/src/pages/AnnotationLayerList/AnnotationLayerList.test.jsx index bdb7eb9bd93..874e4a89dcf 100644 --- a/superset-frontend/src/pages/AnnotationLayerList/AnnotationLayerList.test.jsx +++ b/superset-frontend/src/pages/AnnotationLayerList/AnnotationLayerList.test.jsx @@ -19,21 +19,17 @@ import thunk from 'redux-thunk'; import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; -import { Provider } from 'react-redux'; -import { styledMount as mount } from 'spec/helpers/theming'; +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; import AnnotationLayersList from 'src/pages/AnnotationLayerList'; -import AnnotationLayerModal from 'src/features/annotationLayers/AnnotationLayerModal'; -import SubMenu from 'src/features/home/SubMenu'; -import ListView from 'src/components/ListView'; -import Filters from 'src/components/ListView/Filters'; -import DeleteModal from 'src/components/DeleteModal'; -import Button from 'src/components/Button'; -import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; -import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; -import { act } from 'spec/helpers/testing-library'; -// store needed for withToasts(AnnotationLayersList) const mockStore = configureStore([thunk]); const store = mockStore({}); @@ -77,91 +73,127 @@ fetchMock.get(layersRelatedEndpoint, { }, }); -describe('AnnotationLayersList', () => { - const wrapper = mount( - - - , +const renderAnnotationLayersList = (props = {}) => + render( + + + + + , + { + useRedux: true, + store, + }, ); - beforeAll(async () => { - await waitForComponentToPaint(wrapper); + +describe('AnnotationLayersList', () => { + beforeEach(() => { + fetchMock.resetHistory(); }); - it('renders', () => { - expect(wrapper.find(AnnotationLayersList)).toBeTruthy(); + it('renders', async () => { + renderAnnotationLayersList(); + expect(await screen.findByText('Annotation layers')).toBeInTheDocument(); }); - it('renders a SubMenu', () => { - expect(wrapper.find(SubMenu)).toBeTruthy(); + it('renders a SubMenu', async () => { + renderAnnotationLayersList(); + expect(await screen.findByRole('navigation')).toBeInTheDocument(); }); - it('renders a ListView', () => { - expect(wrapper.find(ListView)).toBeTruthy(); + it('renders a ListView', async () => { + renderAnnotationLayersList(); + expect( + await screen.findByTestId('annotation-layers-list-view'), + ).toBeInTheDocument(); }); - it('renders a modal', () => { - expect(wrapper.find(AnnotationLayerModal)).toBeTruthy(); + it('renders a modal', async () => { + renderAnnotationLayersList(); + const addButton = await screen.findByRole('button', { + name: /annotation layer$/i, + }); + fireEvent.click(addButton); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); }); - it('fetches layers', () => { - const callsQ = fetchMock.calls(/annotation_layer\/\?q/); - expect(callsQ).toHaveLength(1); - expect(callsQ[0][0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/annotation_layer/?q=(order_column:name,order_direction:desc,page:0,page_size:25)"`, - ); + it('fetches layers', async () => { + renderAnnotationLayersList(); + await waitFor(() => { + const calls = fetchMock.calls(/annotation_layer\/\?q/); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toContain( + 'order_column:name,order_direction:desc,page:0,page_size:25', + ); + }); }); - it('renders Filters', () => { - expect(wrapper.find(Filters)).toBeTruthy(); + it('renders Filters', async () => { + renderAnnotationLayersList(); + await screen.findByTestId('annotation-layers-list-view'); + expect(screen.getByPlaceholderText(/type a value/i)).toBeInTheDocument(); }); it('searches', async () => { - const filtersWrapper = wrapper.find(Filters); - act(() => { - filtersWrapper.find('[name="name"]').first().props().onSubmit('foo'); - }); - await waitForComponentToPaint(wrapper); + renderAnnotationLayersList(); - expect(fetchMock.lastCall()[0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/annotation_layer/?q=(filters:!((col:name,opr:ct,value:foo)),order_column:name,order_direction:desc,page:0,page_size:25)"`, - ); + // Wait for list to load + await screen.findByTestId('annotation-layers-list-view'); + + // Find and fill search input + const searchInput = screen.getByPlaceholderText(/type a value/i); + fireEvent.change(searchInput, { target: { value: 'foo' } }); + fireEvent.keyDown(searchInput, { key: 'Enter', keyCode: 13 }); + + // Wait for search API call + await waitFor(() => { + const calls = fetchMock.calls(/annotation_layer\/\?q/); + const searchCall = calls.find(call => + call[0].includes('filters:!((col:name,opr:ct,value:foo))'), + ); + expect(searchCall).toBeTruthy(); + }); }); it('deletes', async () => { - act(() => { - wrapper.find('[data-test="delete-action"]').first().props().onClick(); + renderAnnotationLayersList(); + + // Wait for list to load + await screen.findByTestId('annotation-layers-list-view'); + + // Find and click delete button + const deleteButtons = await screen.findAllByTestId('delete-action'); + fireEvent.click(deleteButtons[0]); + + // Check delete modal content + const deleteModal = await screen.findByRole('dialog'); + expect(deleteModal).toHaveTextContent(/permanently delete the layer/i); + + // Type DELETE in confirmation input + const deleteInput = await screen.findByTestId('delete-modal-input'); + fireEvent.change(deleteInput, { target: { value: 'DELETE' } }); + + // Click confirm button + const confirmButton = await screen.findByTestId('modal-confirm-button'); + fireEvent.click(confirmButton); + + // Wait for delete request + await waitFor(() => { + expect(fetchMock.calls(/annotation_layer\/0/, 'DELETE')).toHaveLength(1); }); - await waitForComponentToPaint(wrapper); - - expect( - wrapper.find(DeleteModal).first().props().description, - ).toMatchInlineSnapshot(`"This action will permanently delete the layer."`); - - act(() => { - wrapper - .find('#delete') - .first() - .props() - .onChange({ target: { value: 'DELETE' } }); - }); - await waitForComponentToPaint(wrapper); - act(() => { - wrapper.find('button').last().props().onClick(); - }); - - await waitForComponentToPaint(wrapper); - - expect(fetchMock.calls(/annotation_layer\/0/, 'DELETE')).toHaveLength(1); }); - it('shows/hides bulk actions when bulk actions is clicked', async () => { - const button = wrapper.find(Button).at(1); - act(() => { - button.props().onClick(); - }); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(IndeterminateCheckbox)).toHaveLength( - mocklayers.length + 1, // 1 for each row and 1 for select all - ); - }); + it('shows bulk actions when bulk select is clicked', async () => { + renderAnnotationLayersList(); + + // Wait for list to load + await screen.findByTestId('annotation-layers-list-view'); + + // Click bulk select toggle + const bulkSelectButton = screen.getByText(/bulk select/i); + fireEvent.click(bulkSelectButton); + + // Wait for bulk select mode to be enabled + await screen.findByText('0 Selected'); + }, 30000); }); diff --git a/superset-frontend/src/pages/ChartList/ChartList.test.jsx b/superset-frontend/src/pages/ChartList/ChartList.test.jsx index abfb454b182..3da03f035b5 100644 --- a/superset-frontend/src/pages/ChartList/ChartList.test.jsx +++ b/superset-frontend/src/pages/ChartList/ChartList.test.jsx @@ -22,34 +22,18 @@ import configureStore from 'redux-mock-store'; import * as reactRedux from 'react-redux'; import fetchMock from 'fetch-mock'; import { VizType, isFeatureEnabled } from '@superset-ui/core'; -import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; -import { styledMount as mount } from 'spec/helpers/theming'; import { - act, - cleanup, render, screen, - userEvent, + fireEvent, + waitFor, } from 'spec/helpers/testing-library'; import { QueryParamProvider } from 'use-query-params'; import ChartList from 'src/pages/ChartList'; -import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; -import ListView from 'src/components/ListView'; -import PropertiesModal from 'src/explore/components/PropertiesModal'; -import ListViewCard from 'src/components/ListViewCard'; -import FaveStar from 'src/components/FaveStar'; -import TableCollection from 'src/components/TableCollection'; -import CardCollection from 'src/components/ListView/CardCollection'; -const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*'; -const chartsOwnersEndpoint = 'glob:*/api/v1/chart/related/owners*'; -const chartsCreatedByEndpoint = 'glob:*/api/v1/chart/related/created_by*'; -const chartsEndpoint = 'glob:*/api/v1/chart/*'; -const chartsVizTypesEndpoint = 'glob:*/api/v1/chart/viz_types'; -const chartsDatasourcesEndpoint = 'glob:*/api/v1/chart/datasources'; -const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status*'; -const datasetEndpoint = 'glob:*/api/v1/dataset/*'; +// Increase default timeout for all tests +jest.setTimeout(30000); jest.mock('@superset-ui/core', () => ({ ...jest.requireActual('@superset-ui/core'), @@ -71,10 +55,18 @@ const mockUser = { userId: 1, }; +const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*'; +const chartsOwnersEndpoint = 'glob:*/api/v1/chart/related/owners*'; +const chartsCreatedByEndpoint = 'glob:*/api/v1/chart/related/created_by*'; +const chartsEndpoint = 'glob:*/api/v1/chart/*'; +const chartsVizTypesEndpoint = 'glob:*/api/v1/chart/viz_types'; +const chartsDatasourcesEndpoint = 'glob:*/api/v1/chart/datasources'; +const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status*'; +const datasetEndpoint = 'glob:*/api/v1/dataset/*'; + fetchMock.get(chartsInfoEndpoint, { permissions: ['can_read', 'can_write'], }); - fetchMock.get(chartsOwnersEndpoint, { result: [], }); @@ -82,23 +74,20 @@ fetchMock.get(chartsCreatedByEndpoint, { result: [], }); fetchMock.get(chartFavoriteStatusEndpoint, { - result: [], + result: mockCharts.map(chart => ({ id: chart.id, value: true })), }); fetchMock.get(chartsEndpoint, { result: mockCharts, chart_count: 3, }); - fetchMock.get(chartsVizTypesEndpoint, { result: [], count: 0, }); - fetchMock.get(chartsDatasourcesEndpoint, { result: [], count: 0, }); - fetchMock.get(datasetEndpoint, {}); global.URL.createObjectURL = jest.fn(); @@ -122,169 +111,225 @@ const user = { username: 'admin', }; -// store needed for withToasts(DatabaseList) const mockStore = configureStore([thunk]); const store = mockStore({ user }); const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); -describe('ChartList', () => { - isFeatureEnabled.mockImplementation( - feature => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW', +const renderChartList = (props = {}) => + render( + + + + + , + { + useRedux: true, + store, + }, ); +describe('ChartList', () => { + beforeEach(() => { + isFeatureEnabled.mockImplementation( + feature => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW', + ); + fetchMock.resetHistory(); + useSelectorMock.mockClear(); + }); + afterAll(() => { isFeatureEnabled.mockRestore(); }); - beforeEach(() => { - // setup a DOM element as a render target - useSelectorMock.mockClear(); + it('renders', async () => { + renderChartList(); + expect(await screen.findByText('Charts')).toBeInTheDocument(); }); - const mockedProps = {}; - - let wrapper; - - beforeAll(async () => { - wrapper = mount( - - - - - , - ); - - await waitForComponentToPaint(wrapper); + it('renders a ListView', async () => { + renderChartList(); + expect(await screen.findByTestId('chart-list-view')).toBeInTheDocument(); }); - it('renders', () => { - expect(wrapper.find(ChartList)).toBeTruthy(); + it('fetches info', async () => { + renderChartList(); + await waitFor(() => { + const calls = fetchMock.calls(/chart\/_info/); + expect(calls).toHaveLength(1); + }); }); - it('renders a ListView', () => { - expect(wrapper.find(ListView)).toBeTruthy(); - }); - - it('fetches info', () => { - const callsI = fetchMock.calls(/chart\/_info/); - expect(callsI).toHaveLength(1); - }); - - it('fetches data', () => { - wrapper.update(); - const callsD = fetchMock.calls(/chart\/\?q/); - expect(callsD).toHaveLength(1); - expect(callsD[0][0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/chart/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, - ); - }); - - it('renders a card view', () => { - expect(wrapper.find(ListViewCard)).toBeTruthy(); - }); - - it('renders a table view', async () => { - wrapper.find('[aria-label="list-view"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find('table')).toBeTruthy(); - }); - - it('edits', async () => { - expect(wrapper.find(PropertiesModal).length).toBe(0); - wrapper.find('[data-test="edit-alt"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(PropertiesModal).length).toBeGreaterThan(0); - }); - - it('delete', async () => { - wrapper.find('[data-test="trash"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(ConfirmStatusChange)).toBeTruthy(); - }); - - it('renders the Favorite Star column in list view for logged in user', async () => { - wrapper.find('[aria-label="list-view"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(TableCollection).find(FaveStar)).toBeTruthy(); - }); - - it('renders the Favorite Star in card view for logged in user', async () => { - wrapper.find('[aria-label="card-view"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(CardCollection).find(FaveStar)).toBeTruthy(); - }); -}); - -describe('RTL', () => { - async function renderAndWait() { - const mounted = act(async () => { - const mockedProps = {}; - render( - - - , - { useRedux: true, useRouter: true }, + it('fetches data', async () => { + renderChartList(); + await waitFor(() => { + const calls = fetchMock.calls(/chart\/\?q/); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toContain( + 'order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25', ); }); - - return mounted; - } - - beforeEach(async () => { - isFeatureEnabled.mockImplementation(() => true); - await renderAndWait(); }); - afterEach(() => { - cleanup(); - isFeatureEnabled.mockRestore(); + it('switches between card and table view', async () => { + renderChartList(); + + // Wait for list to load + await screen.findByTestId('chart-list-view'); + + // Find and click list view toggle + const listViewToggle = await screen.findByRole('img', { + name: 'list-view', + }); + const listViewButton = listViewToggle.closest('[role="button"]'); + fireEvent.click(listViewButton); + + // Wait for list view to be active + await waitFor(() => { + const listViewToggle = screen.getByRole('img', { name: 'list-view' }); + expect(listViewToggle.closest('[role="button"]')).toHaveClass('active'); + }); + + // Find and click card view toggle + const cardViewToggle = screen.getByRole('img', { name: 'card-view' }); + const cardViewButton = cardViewToggle.closest('[role="button"]'); + fireEvent.click(cardViewButton); + + // Wait for card view to be active + await waitFor(() => { + const cardViewToggle = screen.getByRole('img', { name: 'card-view' }); + expect(cardViewToggle.closest('[role="button"]')).toHaveClass('active'); + }); + }); + + it('shows edit modal', async () => { + renderChartList(); + + // Wait for list to load + await screen.findByTestId('chart-list-view'); + + // Switch to list view + const listViewToggle = await screen.findByRole('img', { + name: 'list-view', + }); + const listViewButton = listViewToggle.closest('[role="button"]'); + fireEvent.click(listViewButton); + + // Wait for list view to be active and data to load + await waitFor(() => { + expect(screen.getByText('cool chart 0')).toBeInTheDocument(); + }); + + // Click edit button + const editButtons = await screen.findAllByTestId('edit-alt'); + fireEvent.click(editButtons[0]); + + // Verify modal appears + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); + + it('shows delete modal', async () => { + renderChartList(); + + // Wait for list to load + await screen.findByTestId('chart-list-view'); + + // Switch to list view + const listViewToggle = await screen.findByRole('img', { + name: 'list-view', + }); + const listViewButton = listViewToggle.closest('[role="button"]'); + fireEvent.click(listViewButton); + + // Wait for list view to be active and data to load + await waitFor(() => { + expect(screen.getByText('cool chart 0')).toBeInTheDocument(); + }); + + // Click delete button + const deleteButtons = await screen.findAllByTestId('trash'); + fireEvent.click(deleteButtons[0]); + + // Verify modal appears + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); + + it('shows favorite stars for logged in user', async () => { + renderChartList(); + + // Wait for list to load + await screen.findByTestId('chart-list-view'); + + // Switch to list view + const listViewToggle = await screen.findByRole('img', { + name: 'list-view', + }); + const listViewButton = listViewToggle.closest('[role="button"]'); + fireEvent.click(listViewButton); + + // Wait for list view to be active and data to load + await waitFor(() => { + expect(screen.getByText('cool chart 0')).toBeInTheDocument(); + }); + + // Wait for favorite stars to appear + await waitFor(() => { + const favoriteStars = screen.getAllByRole('img', { + name: 'favorite-selected', + }); + expect(favoriteStars.length).toBeGreaterThan(0); + }); }); it('renders an "Import Chart" tooltip under import button', async () => { - const importButton = await screen.findByTestId('import-button'); - userEvent.hover(importButton); + renderChartList(); - await screen.findByRole('tooltip'); - const importTooltip = screen.getByRole('tooltip', { + const importButton = await screen.findByTestId('import-button'); + fireEvent.mouseEnter(importButton); + + const importTooltip = await screen.findByRole('tooltip', { name: 'Import charts', }); - expect(importTooltip).toBeInTheDocument(); }); }); describe('ChartList - anonymous view', () => { - const mockedProps = {}; - const mockUserLoggedOut = {}; - let wrapper; - - beforeAll(async () => { + beforeEach(() => { fetchMock.resetHistory(); - wrapper = mount( - - - - - , + // Reset favorite status for anonymous user + fetchMock.get( + chartFavoriteStatusEndpoint, + { + result: [], + }, + { overwriteRoutes: true }, ); - - await waitForComponentToPaint(wrapper); }); - afterAll(() => { - cleanup(); - fetchMock.reset(); - }); + it('does not show favorite stars for anonymous user', async () => { + renderChartList({ user: {} }); - it('does not render the Favorite Star column in list view for anonymous user', async () => { - wrapper.find('[aria-label="list-view"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(TableCollection).find(FaveStar).length).toBe(0); - }); + // Wait for list to load + await screen.findByTestId('chart-list-view'); - it('does not render the Favorite Star in card view for anonymous user', async () => { - wrapper.find('[aria-label="card-view"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(CardCollection).find(FaveStar).length).toBe(0); + // Switch to list view + const listViewToggle = await screen.findByRole('img', { + name: 'list-view', + }); + const listViewButton = listViewToggle.closest('[role="button"]'); + fireEvent.click(listViewButton); + + // Wait for list view to be active and data to load + await waitFor(() => { + expect(screen.getByText('cool chart 0')).toBeInTheDocument(); + }); + + // Verify no selected favorite stars are present + await waitFor(() => { + const favoriteStars = screen.queryAllByRole('img', { + name: 'favorite-selected', + }); + expect(favoriteStars).toHaveLength(0); + }); }); }); diff --git a/superset-frontend/src/pages/CssTemplateList/CssTemplateList.test.jsx b/superset-frontend/src/pages/CssTemplateList/CssTemplateList.test.jsx index 6acc948f1ae..e2b237595fd 100644 --- a/superset-frontend/src/pages/CssTemplateList/CssTemplateList.test.jsx +++ b/superset-frontend/src/pages/CssTemplateList/CssTemplateList.test.jsx @@ -18,21 +18,18 @@ */ import thunk from 'redux-thunk'; import configureStore from 'redux-mock-store'; -import { Provider } from 'react-redux'; import fetchMock from 'fetch-mock'; -import { styledMount as mount } from 'spec/helpers/theming'; +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; import CssTemplatesList from 'src/pages/CssTemplateList'; -import SubMenu from 'src/features/home/SubMenu'; -import ListView from 'src/components/ListView'; -import Filters from 'src/components/ListView/Filters'; -import DeleteModal from 'src/components/DeleteModal'; -import Button from 'src/components/Button'; -import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; -import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; -import { act } from 'spec/helpers/testing-library'; -// store needed for withToasts(DatabaseList) const mockStore = configureStore([thunk]); const store = mockStore({}); @@ -75,98 +72,120 @@ fetchMock.get(templatesRelatedEndpoint, { }, }); -describe('CssTemplatesList', () => { - const wrapper = mount( - - - , +const renderCssTemplatesList = (props = {}) => + render( + + + + + , + { + useRedux: true, + store, + }, ); - beforeAll(async () => { - await waitForComponentToPaint(wrapper); +describe('CssTemplatesList', () => { + beforeEach(() => { + fetchMock.resetHistory(); }); - it('renders', () => { - expect(wrapper.find(CssTemplatesList)).toBeTruthy(); + it('renders', async () => { + renderCssTemplatesList(); + expect(await screen.findByText(/css templates/i)).toBeInTheDocument(); }); - it('renders a SubMenu', () => { - expect(wrapper.find(SubMenu)).toBeTruthy(); + it('renders a SubMenu', async () => { + renderCssTemplatesList(); + expect(await screen.findByRole('navigation')).toBeInTheDocument(); }); - it('renders a ListView', () => { - expect(wrapper.find(ListView)).toBeTruthy(); + it('renders a ListView', async () => { + renderCssTemplatesList(); + expect( + await screen.findByTestId('css-templates-list-view'), + ).toBeInTheDocument(); }); - it('fetches templates', () => { - const callsQ = fetchMock.calls(/css_template\/\?q/); - expect(callsQ).toHaveLength(1); - expect(callsQ[0][0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/css_template/?q=(order_column:template_name,order_direction:desc,page:0,page_size:25)"`, - ); + it('fetches templates', async () => { + renderCssTemplatesList(); + await waitFor(() => { + const calls = fetchMock.calls(/css_template\/\?q/); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toContain( + 'order_column:template_name,order_direction:desc,page:0,page_size:25', + ); + }); }); - it('renders Filters', () => { - expect(wrapper.find(Filters)).toBeTruthy(); + it('renders Filters', async () => { + renderCssTemplatesList(); + await screen.findByTestId('css-templates-list-view'); + expect(screen.getByPlaceholderText(/type a value/i)).toBeInTheDocument(); }); it('searches', async () => { - const filtersWrapper = wrapper.find(Filters); - act(() => { - filtersWrapper - .find('[name="template_name"]') - .first() - .props() - .onSubmit('fooo'); + renderCssTemplatesList(); + + // Wait for list to load + await screen.findByTestId('css-templates-list-view'); + + // Find and fill search input + const searchInput = screen.getByPlaceholderText(/type a value/i); + fireEvent.change(searchInput, { target: { value: 'fooo' } }); + fireEvent.keyDown(searchInput, { key: 'Enter', keyCode: 13 }); + + // Wait for search API call + await waitFor(() => { + const calls = fetchMock.calls(/css_template\/\?q/); + const searchCall = calls.find(call => + call[0].includes('filters:!((col:template_name,opr:ct,value:fooo))'), + ); + expect(searchCall).toBeTruthy(); }); - await waitForComponentToPaint(wrapper); - - expect(fetchMock.lastCall()[0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/css_template/?q=(filters:!((col:template_name,opr:ct,value:fooo)),order_column:template_name,order_direction:desc,page:0,page_size:25)"`, - ); - }); - - it('renders a DeleteModal', () => { - expect(wrapper.find(DeleteModal)).toBeTruthy(); }); it('deletes', async () => { - act(() => { - wrapper.find('[data-test="delete-action"]').first().props().onClick(); + renderCssTemplatesList(); + + // Wait for list to load + await screen.findByTestId('css-templates-list-view'); + + // Find and click delete button + const deleteButtons = await screen.findAllByTestId('delete-action'); + fireEvent.click(deleteButtons[0]); + + // Check delete modal content + const deleteModal = await screen.findByRole('dialog'); + expect(deleteModal).toHaveTextContent(/permanently delete the template/i); + + // Type DELETE in confirmation input + const deleteInput = await screen.findByTestId('delete-modal-input'); + fireEvent.change(deleteInput, { target: { value: 'DELETE' } }); + + // Click confirm button + const confirmButton = await screen.findByTestId('modal-confirm-button'); + fireEvent.click(confirmButton); + + // Wait for delete request + await waitFor(() => { + expect(fetchMock.calls(/css_template\/0/, 'DELETE')).toHaveLength(1); }); - await waitForComponentToPaint(wrapper); - - expect( - wrapper.find(DeleteModal).first().props().description, - ).toMatchInlineSnapshot( - `"This action will permanently delete the template."`, - ); - - act(() => { - wrapper - .find('#delete') - .first() - .props() - .onChange({ target: { value: 'DELETE' } }); - }); - await waitForComponentToPaint(wrapper); - act(() => { - wrapper.find('button').last().props().onClick(); - }); - - await waitForComponentToPaint(wrapper); - - expect(fetchMock.calls(/css_template\/0/, 'DELETE')).toHaveLength(1); }); - it('shows/hides bulk actions when bulk actions is clicked', async () => { - const button = wrapper.find(Button).at(1); - act(() => { - button.props().onClick(); + it('shows bulk actions when bulk select is clicked', async () => { + renderCssTemplatesList(); + + // Wait for list to load + await screen.findByTestId('css-templates-list-view'); + + // Click bulk select toggle + const bulkSelectButton = screen.getByRole('button', { + name: /bulk select/i, }); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(IndeterminateCheckbox)).toHaveLength( - mocktemplates.length + 1, // 1 for each row and 1 for select all - ); - }); + fireEvent.click(bulkSelectButton); + + // Wait for bulk select mode to be enabled + await screen.findByText('0 Selected'); + }, 30000); }); diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.test.jsx b/superset-frontend/src/pages/DashboardList/DashboardList.test.jsx index 7d710f8b2d0..3ea08cbe10f 100644 --- a/superset-frontend/src/pages/DashboardList/DashboardList.test.jsx +++ b/superset-frontend/src/pages/DashboardList/DashboardList.test.jsx @@ -17,31 +17,17 @@ * under the License. */ import { MemoryRouter } from 'react-router-dom'; -import thunk from 'redux-thunk'; -import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; -import * as reactRedux from 'react-redux'; import { isFeatureEnabled } from '@superset-ui/core'; - -import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; -import { styledMount as mount } from 'spec/helpers/theming'; import { - act, - cleanup, render, screen, - userEvent, + waitFor, + fireEvent, } from 'spec/helpers/testing-library'; import { QueryParamProvider } from 'use-query-params'; -import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import DashboardList from 'src/pages/DashboardList'; -import ListView from 'src/components/ListView'; -import ListViewCard from 'src/components/ListViewCard'; -import PropertiesModal from 'src/dashboard/components/PropertiesModal'; -import FaveStar from 'src/components/FaveStar'; -import TableCollection from 'src/components/TableCollection'; -import CardCollection from 'src/components/ListView/CardCollection'; const dashboardsInfoEndpoint = 'glob:*/api/v1/dashboard/_info*'; const dashboardOwnersEndpoint = 'glob:*/api/v1/dashboard/related/owners*'; @@ -87,219 +73,163 @@ fetchMock.get(dashboardCreatedByEndpoint, { fetchMock.get(dashboardFavoriteStatusEndpoint, { result: [], }); - fetchMock.get(dashboardsEndpoint, { result: mockDashboards, dashboard_count: 3, }); - fetchMock.get(dashboardEndpoint, { result: mockDashboards[0], }); global.URL.createObjectURL = jest.fn(); fetchMock.get('/thumbnail', { body: new Blob(), sendAsJson: false }); -const user = { - createdOn: '2021-04-27T18:12:38.952304', - email: 'admin', - firstName: 'admin', - isActive: true, - lastName: 'admin', - permissions: {}, - roles: { - Admin: [ - ['can_sqllab', 'Superset'], - ['can_write', 'Dashboard'], - ['can_write', 'Chart'], - ], - }, - userId: 1, - username: 'admin', -}; - -// store needed for withToasts(DatabaseList) -const mockStore = configureStore([thunk]); -const store = mockStore({ user }); -const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); describe('DashboardList', () => { - isFeatureEnabled.mockImplementation( - feature => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW', - ); + const renderDashboardList = (props = {}, userProp = mockUser) => + render( + + + + + , + { useRedux: true }, + ); - afterAll(() => { + beforeEach(() => { + isFeatureEnabled.mockImplementation( + feature => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW', + ); + fetchMock.resetHistory(); + }); + + afterEach(() => { isFeatureEnabled.mockRestore(); }); - beforeEach(() => { - // setup a DOM element as a render target - useSelectorMock.mockClear(); + it('renders', async () => { + renderDashboardList(); + expect(await screen.findByText('Dashboards')).toBeInTheDocument(); }); - const mockedProps = {}; - let wrapper; - - beforeAll(async () => { - fetchMock.resetHistory(); - wrapper = mount( - - - - - , - ); - - await waitForComponentToPaint(wrapper); + it('renders a ListView', async () => { + renderDashboardList(); + expect( + await screen.findByTestId('dashboard-list-view'), + ).toBeInTheDocument(); }); - it('renders', () => { - expect(wrapper.find(DashboardList)).toBeTruthy(); + it('fetches info', async () => { + renderDashboardList(); + await waitFor(() => { + const calls = fetchMock.calls(/dashboard\/_info/); + expect(calls).toHaveLength(1); + }); }); - it('renders a ListView', () => { - expect(wrapper.find(ListView)).toBeTruthy(); - }); + it('fetches data', async () => { + renderDashboardList(); + await waitFor(() => { + const calls = fetchMock.calls(/dashboard\/\?q/); + expect(calls).toHaveLength(1); + }); - it('fetches info', () => { - const callsI = fetchMock.calls(/dashboard\/_info/); - expect(callsI).toHaveLength(1); - }); - - it('fetches data', () => { - wrapper.update(); - const callsD = fetchMock.calls(/dashboard\/\?q/); - expect(callsD).toHaveLength(1); - expect(callsD[0][0]).toMatchInlineSnapshot( + const calls = fetchMock.calls(/dashboard\/\?q/); + expect(calls[0][0]).toMatchInlineSnapshot( `"http://localhost/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25,select_columns:!(id,dashboard_title,published,url,slug,changed_by,changed_by.id,changed_by.first_name,changed_by.last_name,changed_on_delta_humanized,owners,owners.id,owners.first_name,owners.last_name,tags.id,tags.name,tags.type,status,certified_by,certification_details,changed_on))"`, ); }); - it('renders a card view', () => { - expect(wrapper.find(ListViewCard)).toBeTruthy(); + it('switches between card and table view', async () => { + renderDashboardList(); + + // Wait for the list to load + await screen.findByTestId('dashboard-list-view'); + + // Initially in card view + const cardViewIcon = screen.getByRole('img', { name: 'card-view' }); + expect(cardViewIcon).toBeInTheDocument(); + + // Switch to table view + const listViewIcon = screen.getByRole('img', { name: 'list-view' }); + const listViewButton = listViewIcon.closest('[role="button"]'); + fireEvent.click(listViewButton); + + // Switch back to card view + const cardViewButton = cardViewIcon.closest('[role="button"]'); + fireEvent.click(cardViewButton); }); - it('renders a table view', async () => { - wrapper.find('[aria-label="list-view"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find('table')).toBeTruthy(); + it('shows edit modal', async () => { + renderDashboardList(); + + // Wait for data to load + await screen.findByText('title 0'); + + // Find and click the first more options button + const moreIcons = await screen.findAllByRole('img', { name: 'more-vert' }); + fireEvent.click(moreIcons[0]); + + // Click edit from the dropdown + const editButton = await screen.findByTestId( + 'dashboard-card-option-edit-button', + ); + fireEvent.click(editButton); + + // Check for modal + expect(await screen.findByRole('dialog')).toBeInTheDocument(); }); - it('edits', async () => { - expect(wrapper.find(PropertiesModal).length).toBe(0); - wrapper.find('[data-test="edit-alt"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(PropertiesModal).length).toBeGreaterThan(0); + it('shows delete confirmation', async () => { + renderDashboardList(); + + // Wait for data to load + await screen.findByText('title 0'); + + // Find and click the first more options button + const moreIcons = await screen.findAllByRole('img', { name: 'more-vert' }); + fireEvent.click(moreIcons[0]); + + // Click delete from the dropdown + const deleteButton = await screen.findByTestId( + 'dashboard-card-option-delete-button', + ); + fireEvent.click(deleteButton); + + // Check for confirmation dialog + expect( + await screen.findByText(/Are you sure you want to delete/i), + ).toBeInTheDocument(); }); - it('card view edits', async () => { - wrapper.find('[data-test="edit-alt"]').last().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(PropertiesModal)).toBeTruthy(); - }); + it('renders an "Import Dashboard" tooltip', async () => { + renderDashboardList(); - it('delete', async () => { - wrapper - .find('[data-test="dashboard-list-trash-icon"]') - .first() - .simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(ConfirmStatusChange)).toBeTruthy(); - }); - - it('card view delete', async () => { - wrapper - .find('[data-test="dashboard-list-trash-icon"]') - .last() - .simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(ConfirmStatusChange)).toBeTruthy(); - }); - - it('renders the Favorite Star column in list view for logged in user', async () => { - wrapper.find('[aria-label="list-view"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(TableCollection).find(FaveStar)).toBeTruthy(); - }); - - it('renders the Favorite Star in card view for logged in user', async () => { - wrapper.find('[aria-label="card-view"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(CardCollection).find(FaveStar)).toBeTruthy(); - }); -}); - -describe('RTL', () => { - async function renderAndWait() { - const mounted = act(async () => { - const mockedProps = {}; - render( - - - - - , - { useRedux: true }, - ); - }); - - return mounted; - } - - beforeEach(async () => { - isFeatureEnabled.mockImplementation(() => true); - await renderAndWait(); - }); - - afterEach(() => { - cleanup(); - isFeatureEnabled.mockRestore(); - }); - - it('renders an "Import Dashboard" tooltip under import button', async () => { const importButton = await screen.findByTestId('import-button'); - userEvent.hover(importButton); + fireEvent.mouseOver(importButton); - await screen.findByRole('tooltip'); - const importTooltip = screen.getByRole('tooltip', { - name: 'Import dashboards', - }); - - expect(importTooltip).toBeInTheDocument(); + expect( + await screen.findByRole('tooltip', { + name: 'Import dashboards', + }), + ).toBeInTheDocument(); }); }); describe('DashboardList - anonymous view', () => { - const mockedProps = {}; - const mockUserLoggedOut = {}; - let wrapper; - - beforeAll(async () => { - fetchMock.resetHistory(); - wrapper = mount( + it('does not render favorite stars for anonymous user', async () => { + render( - - - + + + , + { useRedux: true }, ); - await waitForComponentToPaint(wrapper); - }); - - afterAll(() => { - cleanup(); - fetchMock.reset(); - }); - - it('does not render the Favorite Star column in list view for anonymous user', async () => { - wrapper.find('[aria-label="list-view"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(TableCollection).find(FaveStar).length).toBe(0); - }); - - it('does not render the Favorite Star in card view for anonymous user', async () => { - wrapper.find('[aria-label="card-view"]').first().simulate('click'); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(CardCollection).find(FaveStar).length).toBe(0); + await waitFor(() => { + expect( + screen.queryByRole('img', { name: /favorite/i }), + ).not.toBeInTheDocument(); + }); }); }); diff --git a/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx b/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx index 127449bc817..91ad7ecb47e 100644 --- a/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx +++ b/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx @@ -17,418 +17,215 @@ * under the License. */ import thunk from 'redux-thunk'; -import * as reactRedux from 'react-redux'; -import { BrowserRouter } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; -import { styledMount as mount } from 'spec/helpers/theming'; import { - act, - cleanup, render, screen, - userEvent, + fireEvent, waitFor, } from 'spec/helpers/testing-library'; +import { MemoryRouter } from 'react-router-dom'; import { QueryParamProvider } from 'use-query-params'; -import { isFeatureEnabled } from '@superset-ui/core'; -import SavedQueryList from 'src/pages/SavedQueryList'; -import SubMenu from 'src/features/home/SubMenu'; -import ListView from 'src/components/ListView'; -import Filters from 'src/components/ListView/Filters'; -import ActionsBar from 'src/components/ListView/ActionsBar'; -import DeleteModal from 'src/components/DeleteModal'; -import Button from 'src/components/Button'; -import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; -import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; +import SavedQueryList from '.'; + +// Increase default timeout +jest.setTimeout(30000); + +const mockQueries = [...new Array(3)].map((_, i) => ({ + created_by: { id: i, first_name: 'user', last_name: `${i}` }, + created_on: `${i}-2020`, + database: { database_name: `db ${i}`, id: i }, + changed_on_delta_humanized: '1 day ago', + db_id: i, + description: `SQL for ${i}`, + id: i, + label: `query ${i}`, + schema: 'public', + sql: `SELECT ${i} FROM table`, + sql_tables: [{ catalog: null, schema: null, table: `${i}` }], + tags: [], +})); + +const mockUser = { + userId: 1, + firstName: 'admin', + lastName: 'admin', +}; const queriesInfoEndpoint = 'glob:*/api/v1/saved_query/_info*'; const queriesEndpoint = 'glob:*/api/v1/saved_query/?*'; const queryEndpoint = 'glob:*/api/v1/saved_query/*'; -const queriesRelatedEndpoint = 'glob:*/api/v1/saved_query/related/database?*'; -const queriesDistinctEndpoint = 'glob:*/api/v1/saved_query/distinct/schema?*'; - -const mockqueries = [...new Array(3)].map((_, i) => ({ - created_by: { - id: i, - first_name: `user`, - last_name: `${i}`, - }, - created_on: `${i}-2020`, - database: { - database_name: `db ${i}`, - id: i, - }, - changed_on_delta_humanized: '1 day ago', - db_id: i, - description: `SQL for ${i}`, - id: i, - label: `query ${i}`, - schema: 'public', - sql: `SELECT ${i} FROM table`, - sql_tables: [ - { - catalog: null, - schema: null, - table: `${i}`, - }, - ], -})); - -const user = { - createdOn: '2021-04-27T18:12:38.952304', - email: 'admin', - firstName: 'admin', - isActive: true, - lastName: 'admin', - permissions: {}, - roles: { - Admin: [ - ['can_sqllab', 'Superset'], - ['can_write', 'Dashboard'], - ['can_write', 'Chart'], - ], - }, - userId: 1, - username: 'admin', -}; - -// store needed for withToasts(DatabaseList) -const mockStore = configureStore([thunk]); -const store = mockStore({ user }); - -const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); - -// ---------- For import testing ---------- -// Create an one more mocked query than the original mocked query array -const mockOneMoreQuery = [...new Array(mockqueries.length + 1)].map((_, i) => ({ - created_by: { - id: i, - first_name: `user`, - last_name: `${i}`, - }, - created_on: `${i}-2020`, - database: { - database_name: `db ${i}`, - id: i, - }, - changed_on_delta_humanized: '1 day ago', - db_id: i, - description: `SQL for ${i}`, - id: i, - label: `query ${i}`, - schema: 'public', - sql: `SELECT ${i} FROM table`, - sql_tables: [ - { - catalog: null, - schema: null, - table: `${i}`, - }, - ], -})); -// Grab the last mocked query, to mock import -const mockNewImportQuery = mockOneMoreQuery.pop(); -// Create a new file out of mocked import query to mock upload -const mockImportFile = new File( - [mockNewImportQuery], - 'saved_query_import_mock.json', -); +const permalinkEndpoint = 'glob:*/api/v1/sqllab/permalink'; fetchMock.get(queriesInfoEndpoint, { permissions: ['can_write', 'can_read', 'can_export'], }); + fetchMock.get(queriesEndpoint, { - result: mockqueries, - count: 3, + ids: [2, 0, 1], + result: mockQueries, + count: mockQueries.length, +}); + +fetchMock.post(permalinkEndpoint, { + url: 'http://localhost/permalink', }); fetchMock.delete(queryEndpoint, {}); -fetchMock.delete(queriesEndpoint, {}); -fetchMock.get(queriesRelatedEndpoint, { - count: 0, - result: [], -}); - -fetchMock.get(queriesDistinctEndpoint, { - count: 0, - result: [], -}); - -// Mock utils module -jest.mock('src/views/CRUD/utils'); - -jest.mock('@superset-ui/core', () => ({ - ...jest.requireActual('@superset-ui/core'), - isFeatureEnabled: jest.fn(), -})); - -describe('SavedQueryList', () => { - const wrapper = mount( - - - , +const renderList = (props = {}, storeOverrides = {}) => + render( + + + + + , + { + useRedux: true, + store: configureStore([thunk])({ + user: { + ...mockUser, + roles: { Admin: [['can_write', 'SavedQuery']] }, + }, + ...storeOverrides, + }), + }, ); +describe('SavedQueryList', () => { beforeEach(() => { - // setup a DOM element as a render target - useSelectorMock.mockClear(); + fetchMock.resetHistory(); }); - beforeAll(async () => { - await waitForComponentToPaint(wrapper); + it('renders', async () => { + renderList(); + expect(await screen.findByText('Saved queries')).toBeInTheDocument(); }); - it('renders', () => { - expect(wrapper.find(SavedQueryList)).toBeTruthy(); - }); - - it('renders a SubMenu', () => { - expect(wrapper.find(SubMenu)).toBeTruthy(); - }); - - it('renders a SubMenu with Saved queries and Query History links', () => { - expect(wrapper.find(SubMenu).props().tabs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ label: 'Saved queries' }), - expect.objectContaining({ label: 'Query history' }), - ]), - ); - }); - - it('renders a SubMenu without Databases and Datasets links', () => { - expect(wrapper.find(SubMenu).props().tabs).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ label: 'Databases' }), - expect.objectContaining({ label: 'Datasets' }), - ]), - ); - }); - - it('renders a ListView', () => { - expect(wrapper.find(ListView)).toBeTruthy(); - }); - - it('fetches saved queries', () => { - const callsQ = fetchMock.calls(/saved_query\/\?q/); - expect(callsQ).toHaveLength(1); - expect(callsQ[0][0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/saved_query/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, - ); - }); - - it('renders ActionsBar in table', () => { - expect(wrapper.find(ActionsBar)).toBeTruthy(); - expect(wrapper.find(ActionsBar)).toHaveLength(3); - }); - - it('deletes', async () => { - act(() => { - wrapper.find('span[data-test="delete-action"]').first().props().onClick(); - }); - await waitForComponentToPaint(wrapper); - + it('renders a ListView', async () => { + renderList(); expect( - wrapper.find(DeleteModal).first().props().description, - ).toMatchInlineSnapshot( - `"This action will permanently delete the saved query."`, - ); - - act(() => { - wrapper - .find('#delete') - .first() - .props() - .onChange({ target: { value: 'DELETE' } }); - }); - await waitForComponentToPaint(wrapper); - act(() => { - wrapper.find('button').last().props().onClick(); - }); - - await waitForComponentToPaint(wrapper); - - expect(fetchMock.calls(/saved_query\/0/, 'DELETE')).toHaveLength(1); + await screen.findByTestId('saved_query-list-view'), + ).toBeInTheDocument(); }); - it('copies a query link when the API succeeds', async () => { - Object.assign(navigator, { - clipboard: { - writeText: jest.fn(), - }, - }); + it('renders query information', async () => { + renderList(); - fetchMock.get('glob:*/api/v1/saved_query', { - result: [ - { - id: 1, - label: 'Test Query', - db_id: 1, - schema: 'public', - sql: 'SELECT * FROM table', - }, - ], - count: 1, - }); - fetchMock.post('glob:*/api/v1/sqllab/permalink', { - body: { url: 'http://example.com/permalink' }, - status: 200, - }); + // Wait for list to load + await screen.findByTestId('saved_query-list-view'); - render( - - - - - , - { store }, - ); - - const copyActionButton = await waitFor( - () => screen.getAllByTestId('copy-action')[0], - ); - userEvent.hover(copyActionButton); - - userEvent.click(copyActionButton); + // Wait for data to load await waitFor(() => { - expect(fetchMock.calls('glob:*/api/v1/sqllab/permalink').length).toBe(1); + mockQueries.forEach(query => { + expect(screen.getByText(query.label)).toBeInTheDocument(); + expect( + screen.getByText(query.database.database_name), + ).toBeInTheDocument(); + expect(screen.getAllByText(query.schema)[0]).toBeInTheDocument(); + }); }); - - expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - 'http://example.com/permalink', - ); }); - it('shows/hides bulk actions when bulk actions is clicked', async () => { - const button = wrapper.find(Button).at(0); - act(() => { - button.props().onClick(); + it('handles query deletion', async () => { + renderList(); + + // Wait for list to load + await screen.findByTestId('saved_query-list-view'); + + // Wait for data and find delete button + const deleteButtons = await screen.findAllByTestId('delete-action'); + fireEvent.click(deleteButtons[0]); + + // Confirm deletion + const deleteInput = screen.getByTestId('delete-modal-input'); + fireEvent.change(deleteInput, { target: { value: 'DELETE' } }); + + const confirmButton = screen.getByRole('button', { name: /delete/i }); + fireEvent.click(confirmButton); + + // Verify API call + await waitFor(() => { + expect(fetchMock.calls(/saved_query\/0/, 'DELETE')).toHaveLength(1); }); - await waitForComponentToPaint(wrapper); - expect(wrapper.find(IndeterminateCheckbox)).toHaveLength( - mockqueries.length + 1, // 1 for each row and 1 for select all - ); }); - it('searches', async () => { - const filtersWrapper = wrapper.find(Filters); - act(() => { - filtersWrapper.find('[name="label"]').first().props().onSubmit('fooo'); - }); - await waitForComponentToPaint(wrapper); + it('handles search filtering', async () => { + renderList(); - expect(fetchMock.lastCall()[0]).toMatchInlineSnapshot( - `"http://localhost/api/v1/saved_query/?q=(filters:!((col:label,opr:all_text,value:fooo)),order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, + // Wait for list to load + await screen.findByTestId('saved_query-list-view'); + + // Find and use search input + const searchInput = screen.getByTestId('filters-search'); + fireEvent.change(searchInput, { target: { value: 'test query' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + // Verify API call + await waitFor(() => { + const calls = fetchMock.calls(/saved_query\/\?q/); + expect(calls.length).toBeGreaterThan(0); + const lastCall = calls[calls.length - 1][0]; + expect(lastCall).toContain('order_column'); + expect(lastCall).toContain('page'); + }); + }); + + it('fetches data', async () => { + renderList(); + await waitFor(() => { + const calls = fetchMock.calls(/saved_query\/\?q/); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toContain('order_column'); + expect(calls[0][0]).toContain('page'); + }); + }); + + it('handles sorting', async () => { + renderList(); + + // Wait for list to load + await screen.findByTestId('saved_query-list-view'); + + // Find and click sort header + const sortHeaders = screen.getAllByTestId('sort-header'); + fireEvent.click(sortHeaders[0]); + + // Verify API call includes sorting + await waitFor(() => { + const calls = fetchMock.calls(/saved_query\/\?q/); + const lastCall = calls[calls.length - 1][0]; + expect(lastCall).toContain('order_column:label'); + }); + }); + + it('shows/hides elements based on permissions', async () => { + // Mock info response without write permission + fetchMock.get( + queriesInfoEndpoint, + { permissions: ['can_read'] }, + { overwriteRoutes: true }, ); - }); -}); - -describe('RTL', () => { - function renderAndWait() { - return render(, { - useRedux: true, - useRouter: true, - useQueryParams: true, - }); - } - - beforeEach(async () => { - isFeatureEnabled.mockImplementation(() => true); - renderAndWait(); - }); - - afterEach(() => { - cleanup(); - isFeatureEnabled.mockRestore(); - }); - it('renders an export button in the bulk actions', async () => { - const bulkSelectButton = screen.getByRole('button', { - name: /bulk select/i, - }); - userEvent.click(bulkSelectButton); - const checkbox = await screen.findByTestId('header-toggle-all'); - userEvent.click(checkbox); - - const exportButton = screen.getByRole('button', { - name: /export/i, - }); - expect(exportButton).toBeVisible(); - }); - - it('renders an export button in the actions bar', async () => { - // Grab Export action button and mock mouse hovering over it - const exportActionButton = screen.getAllByTestId('export-action')[0]; - userEvent.hover(exportActionButton); - - // Wait for the tooltip to pop up - await screen.findByRole('tooltip'); - - // Grab and assert that "Export Query" tooltip is in the document - const exportTooltip = screen.getByRole('tooltip', { - name: /export query/i, - }); - expect(exportTooltip).toBeInTheDocument(); - }); - - it('renders a copy button in the actions bar', async () => { - // Grab copy action button and mock mouse hovering over it - const copyActionButton = screen.getAllByTestId('copy-action')[0]; - userEvent.hover(copyActionButton); - - // Wait for the tooltip to pop up - await screen.findByRole('tooltip'); - - // Grab and assert that "Copy query URl" tooltip is in the document - const copyTooltip = screen.getByRole('tooltip', { - name: /Copy query URL/i, - }); - expect(copyTooltip).toBeInTheDocument(); - }); - - it('renders an import button in the submenu', async () => { - // Grab and assert that import saved query button is visible - const importButton = await screen.findByTestId('import-button'); - expect(importButton).toBeVisible(); - }); - - it('renders an "Import Saved Query" tooltip under import button', async () => { - const importButton = await screen.findByTestId('import-icon'); - userEvent.hover(importButton); - - const importTooltip = await screen.findByRole('tooltip', { - name: 'Import queries', - }); - expect(importTooltip).toBeInTheDocument(); - }); - - it('renders an import modal when import button is clicked', async () => { - // Grab and click import saved query button to reveal modal - expect( - screen.queryByRole('heading', { name: 'Import queries' }), - ).not.toBeInTheDocument(); - const importButton = await screen.findByTestId('import-button'); - userEvent.click(importButton); - - // Grab and assert that saved query import modal's heading is visible - const importSavedQueryModalHeading = screen.getByRole('heading', { - name: 'Import queries', - }); - expect(importSavedQueryModalHeading).toBeInTheDocument(); - }); - - it('imports a saved query', async () => { - // Grab and click import saved query button to reveal modal - const importButton = await screen.findByTestId('import-button'); - userEvent.click(importButton); - - // Grab "Choose File" input from import modal - const chooseFileInput = screen.getByTestId('model-file-input'); - // Upload mocked import file - userEvent.upload(chooseFileInput, mockImportFile); - - expect(chooseFileInput.files[0]).toStrictEqual(mockImportFile); - expect(chooseFileInput.files.item(0)).toStrictEqual(mockImportFile); - expect(chooseFileInput.files).toHaveLength(1); + + // Mock list response + fetchMock.get( + queriesEndpoint, + { result: mockQueries, count: mockQueries.length }, + { overwriteRoutes: true }, + ); + + renderList(); + + // Wait for list to load + await screen.findByTestId('saved_query-list-view'); + + // Wait for data to load + await waitFor(() => { + expect(screen.getByText(mockQueries[0].label)).toBeInTheDocument(); + }); + + // Verify delete buttons are not shown + expect(screen.queryByTestId('delete-action')).not.toBeInTheDocument(); }); });