diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts index 488caaa600..8999a2b574 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts @@ -328,6 +328,7 @@ export type Query = { actions: Record; type: DatasourceType; columns: QueryColumn[]; + runAsync?: boolean; }; export type QueryResults = { diff --git a/superset-frontend/spec/helpers/reducerIndex.ts b/superset-frontend/spec/helpers/reducerIndex.ts index 95fe4d3f1c..a4e0021839 100644 --- a/superset-frontend/spec/helpers/reducerIndex.ts +++ b/superset-frontend/spec/helpers/reducerIndex.ts @@ -39,8 +39,8 @@ const common = { ...bootstrapData.common }; const user = { ...bootstrapData.user }; const noopReducer = - (initialState: unknown) => - (state = initialState) => + (initialState: STATE) => + (state: STATE = initialState) => state; export default { diff --git a/superset-frontend/src/SqlLab/components/App/App.test.jsx b/superset-frontend/src/SqlLab/components/App/App.test.tsx similarity index 86% rename from superset-frontend/src/SqlLab/components/App/App.test.jsx rename to superset-frontend/src/SqlLab/components/App/App.test.tsx index d3db1d5fb8..b609419cb1 100644 --- a/superset-frontend/src/SqlLab/components/App/App.test.jsx +++ b/superset-frontend/src/SqlLab/components/App/App.test.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { combineReducers } from 'redux'; +import { AnyAction, combineReducers } from 'redux'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { render } from 'spec/helpers/testing-library'; @@ -38,18 +38,15 @@ jest.mock('src/SqlLab/components/QueryAutoRefresh', () => () => (
)); -const sqlLabReducer = combineReducers(reducers); +const sqlLabReducer = combineReducers({ + localStorageUsageInKilobytes: reducers.localStorageUsageInKilobytes, +}); +const mockAction = {} as AnyAction; describe('SqlLab App', () => { const middlewares = [thunk]; const mockStore = configureStore(middlewares); - const store = mockStore(sqlLabReducer(undefined, {}), {}); - beforeEach(() => { - jest.useFakeTimers(); - }); - afterEach(() => { - jest.useRealTimers(); - }); + const store = mockStore(sqlLabReducer(undefined, mockAction)); it('is valid', () => { expect(React.isValidElement()).toBe(true); @@ -61,15 +58,13 @@ describe('SqlLab App', () => { expect(getByTestId('mock-tabbed-sql-editors')).toBeInTheDocument(); }); - it('logs current usage warning', () => { + it('logs current usage warning', async () => { const localStorageUsageInKilobytes = LOCALSTORAGE_MAX_USAGE_KB + 10; + const initialState = { + localStorageUsageInKilobytes, + }; const storeExceedLocalStorage = mockStore( - sqlLabReducer( - { - localStorageUsageInKilobytes, - }, - {}, - ), + sqlLabReducer(initialState, mockAction), ); const { rerender } = render(, { @@ -87,14 +82,14 @@ describe('SqlLab App', () => { ); }); - it('logs current local storage usage', () => { + it('logs current local storage usage', async () => { const localStorageUsageInKilobytes = LOCALSTORAGE_MAX_USAGE_KB - 10; const storeExceedLocalStorage = mockStore( sqlLabReducer( { localStorageUsageInKilobytes, }, - {}, + mockAction, ), ); diff --git a/superset-frontend/src/SqlLab/components/App/index.jsx b/superset-frontend/src/SqlLab/components/App/index.tsx similarity index 85% rename from superset-frontend/src/SqlLab/components/App/index.jsx rename to superset-frontend/src/SqlLab/components/App/index.tsx index b830454e19..4d2e1d222c 100644 --- a/superset-frontend/src/SqlLab/components/App/index.jsx +++ b/superset-frontend/src/SqlLab/components/App/index.tsx @@ -17,8 +17,6 @@ * under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { Redirect } from 'react-router-dom'; import { css, styled, t } from '@superset-ui/core'; @@ -28,7 +26,8 @@ import { LOCALSTORAGE_WARNING_THRESHOLD, LOCALSTORAGE_WARNING_MESSAGE_THROTTLE_MS, } from 'src/SqlLab/constants'; -import * as Actions from 'src/SqlLab/actions/sqlLab'; +import { addDangerToast } from 'src/components/MessageToasts/actions'; +import type { SqlLabRootState } from 'src/SqlLab/types'; import { logEvent } from 'src/logger/actions'; import { LOG_ACTIONS_SQLLAB_WARN_LOCAL_STORAGE_USAGE, @@ -100,8 +99,21 @@ const SqlLabStyles = styled.div` `}; `; -class App extends React.PureComponent { - constructor(props) { +type PureProps = { + // add this for testing componentDidUpdate spec + updated?: boolean; +}; + +type AppProps = ReturnType & PureProps; + +interface AppState { + hash: string; +} + +class App extends React.PureComponent { + hasLoggedLocalStorageUsage: boolean; + + constructor(props: AppProps) { super(props); this.state = { hash: window.location.hash, @@ -125,7 +137,7 @@ class App extends React.PureComponent { componentDidUpdate() { const { localStorageUsageInKilobytes, actions, queries } = this.props; - const queryCount = queries?.lenghth || 0; + const queryCount = Object.keys(queries || {}).length || 0; if ( localStorageUsageInKilobytes >= LOCALSTORAGE_WARNING_THRESHOLD * LOCALSTORAGE_MAX_USAGE_KB @@ -159,7 +171,7 @@ class App extends React.PureComponent { this.setState({ hash: window.location.hash }); } - showLocalStorageUsageWarning(currentUsage, queryCount) { + showLocalStorageUsageWarning(currentUsage: number, queryCount: number) { this.props.actions.addDangerToast( t( "SQL Lab uses your browser's local storage to store queries and results." + @@ -190,7 +202,6 @@ class App extends React.PureComponent { ); @@ -207,13 +218,7 @@ class App extends React.PureComponent { } } -App.propTypes = { - actions: PropTypes.object, - common: PropTypes.object, - localStorageUsageInKilobytes: PropTypes.number.isRequired, -}; - -function mapStateToProps(state) { +function mapStateToProps(state: SqlLabRootState) { const { common, localStorageUsageInKilobytes, sqlLab } = state; return { common, @@ -223,10 +228,21 @@ function mapStateToProps(state) { }; } -function mapDispatchToProps(dispatch) { +const mapDispatchToProps = { + addDangerToast, + logEvent, +}; + +function mergeProps( + stateProps: ReturnType, + dispatchProps: typeof mapDispatchToProps, + state: PureProps, +) { return { - actions: bindActionCreators({ ...Actions, logEvent }, dispatch), + ...state, + ...stateProps, + actions: dispatchProps, }; } -export default connect(mapStateToProps, mapDispatchToProps)(App); +export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(App); diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index 8213253685..23de528066 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -103,7 +103,7 @@ import SaveQuery, { QueryPayload } from '../SaveQuery'; import ScheduleQueryButton from '../ScheduleQueryButton'; import EstimateQueryCostButton from '../EstimateQueryCostButton'; import ShareSqlLabQuery from '../ShareSqlLabQuery'; -import SqlEditorLeftBar, { ExtendedTable } from '../SqlEditorLeftBar'; +import SqlEditorLeftBar from '../SqlEditorLeftBar'; import AceEditorWrapper from '../AceEditorWrapper'; import RunQueryActionButton from '../RunQueryActionButton'; import QueryLimitSelect from '../QueryLimitSelect'; @@ -215,7 +215,6 @@ const StyledSqlEditor = styled.div` const extensionsRegistry = getExtensionsRegistry(); export type Props = { - tables: ExtendedTable[]; queryEditor: QueryEditor; defaultQueryLimit: number; maxRow: number; @@ -235,7 +234,6 @@ const elementStyle = ( }); const SqlEditor: React.FC = ({ - tables, queryEditor, defaultQueryLimit, maxRow, @@ -839,7 +837,6 @@ const SqlEditor: React.FC = ({ setShowEmptyState(bool)} /> diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx similarity index 54% rename from superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx rename to superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx index 6665091572..f89c842b15 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx @@ -20,16 +20,19 @@ import React from 'react'; import fetchMock from 'fetch-mock'; import { render, screen, waitFor } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; -import { Provider } from 'react-redux'; -import '@testing-library/jest-dom/extend-expect'; -import SqlEditorLeftBar from 'src/SqlLab/components/SqlEditorLeftBar'; -import { table, initialState, defaultQueryEditor } from 'src/SqlLab/fixtures'; -import { api } from 'src/hooks/apiResources/queryApi'; -import { setupStore } from 'src/views/store'; -import reducers from 'spec/helpers/reducerIndex'; +import SqlEditorLeftBar, { + SqlEditorLeftBarProps, +} from 'src/SqlLab/components/SqlEditorLeftBar'; +import { + table, + initialState, + defaultQueryEditor, + extraQueryEditor1, +} from 'src/SqlLab/fixtures'; +import type { RootState } from 'src/views/store'; +import type { Store } from 'redux'; const mockedProps = { - tables: [table], queryEditorId: defaultQueryEditor.id, database: { id: 1, @@ -39,115 +42,117 @@ const mockedProps = { height: 0, }; -let store; -let actions; - -const logAction = () => next => action => { - if (typeof action === 'function') { - return next(action); - } - actions.push(action); - return next(action); -}; - -const createStore = initState => - setupStore({ - disableDegugger: true, - initialState: initState, - rootReducers: reducers, - middleware: getDefaultMiddleware => - getDefaultMiddleware().concat(api.middleware, logAction), - }); - beforeEach(() => { - store = createStore(initialState); - actions = []; fetchMock.get('glob:*/api/v1/database/?*', { result: [] }); fetchMock.get('glob:*/api/v1/database/*/schemas/?*', { count: 2, result: ['main', 'new_schema'], }); fetchMock.get('glob:*/api/v1/database/*/tables/*', { - count: 1, + count: 2, result: [ { label: 'ab_user', value: 'ab_user', }, + { + label: 'new_table', + value: 'new_table', + }, ], }); + fetchMock.get('glob:*/api/v1/database/*/table/*/*', { + status: 200, + body: { + columns: table.columns, + }, + }); + fetchMock.get('glob:*/api/v1/database/*/table_extra/*/*', { + status: 200, + body: {}, + }); }); afterEach(() => { fetchMock.restore(); - store.dispatch(api.util.resetApiState()); jest.clearAllMocks(); }); -const renderAndWait = (props, store) => +const renderAndWait = ( + props: SqlEditorLeftBarProps, + store?: Store, + initialState?: RootState, +) => waitFor(() => render(, { useRedux: true, + initialState, ...(store && { store }), }), ); -test('is valid', () => { - expect( - React.isValidElement( - - - , - ), - ).toBe(true); -}); - test('renders a TableElement', async () => { - await renderAndWait(mockedProps, store); - expect(await screen.findByText(/Database/i)).toBeInTheDocument(); - const tableElement = screen.getAllByTestId('table-element'); + const { findByText, getAllByTestId } = await renderAndWait( + mockedProps, + undefined, + { ...initialState, sqlLab: { ...initialState.sqlLab, tables: [table] } }, + ); + expect(await findByText(/Database/i)).toBeInTheDocument(); + const tableElement = getAllByTestId('table-element'); expect(tableElement.length).toBeGreaterThanOrEqual(1); }); test('table should be visible when expanded is true', async () => { - const { container } = await renderAndWait(mockedProps, store); + const { container, getByText, getByRole, queryAllByText } = + await renderAndWait(mockedProps, undefined, { + ...initialState, + sqlLab: { ...initialState.sqlLab, tables: [table] }, + }); - const dbSelect = screen.getByRole('combobox', { + const dbSelect = getByRole('combobox', { name: 'Select database or type to search databases', }); - const schemaSelect = screen.getByRole('combobox', { + const schemaSelect = getByRole('combobox', { name: 'Select schema or type to search schemas', }); - const dropdown = screen.getByText(/Table/i); - const abUser = screen.queryAllByText(/ab_user/i); + const dropdown = getByText(/Table/i); + const abUser = queryAllByText(/ab_user/i); - await waitFor(() => { - expect(screen.getByText(/Database/i)).toBeInTheDocument(); - expect(dbSelect).toBeInTheDocument(); - expect(schemaSelect).toBeInTheDocument(); - expect(dropdown).toBeInTheDocument(); - expect(abUser).toHaveLength(2); - expect( - container.querySelector('.ant-collapse-content-active'), - ).toBeInTheDocument(); + expect(getByText(/Database/i)).toBeInTheDocument(); + expect(dbSelect).toBeInTheDocument(); + expect(schemaSelect).toBeInTheDocument(); + expect(dropdown).toBeInTheDocument(); + expect(abUser).toHaveLength(2); + expect( + container.querySelector('.ant-collapse-content-active'), + ).toBeInTheDocument(); + table.columns.forEach(({ name }) => { + expect(getByText(name)).toBeInTheDocument(); }); }); test('should toggle the table when the header is clicked', async () => { - await renderAndWait(mockedProps, store); + const { container } = await renderAndWait(mockedProps, undefined, { + ...initialState, + sqlLab: { ...initialState.sqlLab, tables: [table] }, + }); - const header = (await screen.findAllByText(/ab_user/))[1]; + const header = container.querySelector('.ant-collapse-header'); expect(header).toBeInTheDocument(); - userEvent.click(header); + if (header) { + userEvent.click(header); + } - await waitFor(() => { - expect(actions[actions.length - 1].type).toEqual('COLLAPSE_TABLE'); - }); + await waitFor(() => + expect( + container.querySelector('.ant-collapse-content-inactive'), + ).toBeInTheDocument(), + ); }); test('When changing database the table list must be updated', async () => { - store = createStore({ + const { rerender } = await renderAndWait(mockedProps, undefined, { ...initialState, sqlLab: { ...initialState.sqlLab, @@ -155,9 +160,25 @@ test('When changing database the table list must be updated', async () => { id: defaultQueryEditor.id, schema: 'new_schema', }, + queryEditors: [ + defaultQueryEditor, + { + ...extraQueryEditor1, + schema: 'new_schema', + dbId: 2, + }, + ], + tables: [ + table, + { + ...table, + dbId: 2, + name: 'new_table', + queryEditorId: extraQueryEditor1.id, + }, + ], }, }); - const { rerender } = await renderAndWait(mockedProps, store); expect(screen.getAllByText(/main/i)[0]).toBeInTheDocument(); expect(screen.getAllByText(/ab_user/i)[0]).toBeInTheDocument(); @@ -170,21 +191,18 @@ test('When changing database the table list must be updated', async () => { database_name: 'new_db', backend: 'postgresql', }} - queryEditorId={defaultQueryEditor.id} - tables={[{ ...mockedProps.tables[0], dbId: 2, name: 'new_table' }]} + queryEditorId={extraQueryEditor1.id} />, - { - useRedux: true, - store, - }, ); - expect(await screen.findByText(/new_db/i)).toBeInTheDocument(); - expect(await screen.findByText(/new_table/i)).toBeInTheDocument(); + const updatedDbSelector = await screen.findAllByText(/new_db/i); + expect(updatedDbSelector[0]).toBeInTheDocument(); + const updatedTableSelector = await screen.findAllByText(/new_table/i); + expect(updatedTableSelector[0]).toBeInTheDocument(); }); test('ignore schema api when current schema is deprecated', async () => { const invalidSchemaName = 'None'; - store = createStore({ + await renderAndWait(mockedProps, undefined, { ...initialState, sqlLab: { ...initialState.sqlLab, @@ -192,9 +210,9 @@ test('ignore schema api when current schema is deprecated', async () => { id: defaultQueryEditor.id, schema: invalidSchemaName, }, + tables: [table], }, }); - const { rerender } = await renderAndWait(mockedProps, store); expect(await screen.findByText(/Database/i)).toBeInTheDocument(); expect(fetchMock.calls()).not.toContainEqual( @@ -204,7 +222,6 @@ test('ignore schema api when current schema is deprecated', async () => { ), ]), ); - rerender(); // Deselect the deprecated schema selection await waitFor(() => expect(screen.queryByText(/None/i)).not.toBeInTheDocument(), diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx index eff67d49b1..15a1735626 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx @@ -24,10 +24,10 @@ import React, { Dispatch, SetStateAction, } from 'react'; -import { useDispatch } from 'react-redux'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import querystring from 'query-string'; -import { Table } from 'src/SqlLab/types'; +import { SqlLabRootState, Table } from 'src/SqlLab/types'; import { queryEditorSetDb, addTable, @@ -55,16 +55,11 @@ import { } from 'src/utils/localStorageHelpers'; import TableElement from '../TableElement'; -export interface ExtendedTable extends Table { - expanded: boolean; -} - -interface SqlEditorLeftBarProps { +export interface SqlEditorLeftBarProps { queryEditorId: string; height?: number; - tables?: ExtendedTable[]; database?: DatabaseObject; - setEmptyState: Dispatch>; + setEmptyState?: Dispatch>; } const StyledScrollbarContainer = styled.div` @@ -111,10 +106,14 @@ const LeftBarStyles = styled.div` const SqlEditorLeftBar = ({ database, queryEditorId, - tables = [], height = 500, setEmptyState, }: SqlEditorLeftBarProps) => { + const tables = useSelector( + ({ sqlLab }) => + sqlLab.tables.filter(table => table.queryEditorId === queryEditorId), + shallowEqual, + ); const dispatch = useDispatch(); const queryEditor = useQueryEditor(queryEditorId, ['dbId', 'schema']); @@ -144,7 +143,7 @@ const SqlEditorLeftBar = ({ }; const onDbChange = ({ id: dbId }: { id: number }) => { - setEmptyState(false); + setEmptyState?.(false); dispatch(queryEditorSetDb(queryEditor, dbId)); }; @@ -177,7 +176,7 @@ const SqlEditorLeftBar = ({ }; const onToggleTable = (updatedTables: string[]) => { - tables.forEach((table: ExtendedTable) => { + tables.forEach(table => { if (!updatedTables.includes(table.id.toString()) && table.expanded) { dispatch(collapseTable(table)); } else if ( diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx deleted file mode 100644 index 5d782590a1..0000000000 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx +++ /dev/null @@ -1,228 +0,0 @@ -/** - * 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 React from 'react'; -import configureStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; -import URI from 'urijs'; -import { Provider } from 'react-redux'; -import { shallow, mount } from 'enzyme'; -import { fireEvent, render, waitFor } from 'spec/helpers/testing-library'; -import sinon from 'sinon'; -import { act } from 'react-dom/test-utils'; -import fetchMock from 'fetch-mock'; -import { supersetTheme, ThemeProvider } from '@superset-ui/core'; -import { EditableTabs } from 'src/components/Tabs'; -import TabbedSqlEditors from 'src/SqlLab/components/TabbedSqlEditors'; -import SqlEditor from 'src/SqlLab/components/SqlEditor'; -import { initialState } from 'src/SqlLab/fixtures'; -import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName'; - -fetchMock.get('glob:*/api/v1/database/*', {}); -fetchMock.get('glob:*/api/v1/saved_query/*', {}); -fetchMock.get('glob:*/kv/*', {}); - -describe('TabbedSqlEditors', () => { - const middlewares = [thunk]; - const mockStore = configureStore(middlewares); - const store = mockStore(initialState); - - const queryEditors = [ - { - autorun: false, - dbId: 1, - id: 'newEditorId', - latestQueryId: 'B1-VQU1zW', - schema: null, - selectedText: null, - sql: 'SELECT ds...', - name: 'Untitled Query', - }, - ]; - const mockedProps = { - actions: {}, - databases: {}, - tables: [], - queries: {}, - queryEditors: initialState.sqlLab.queryEditors, - tabHistory: initialState.sqlLab.tabHistory, - editorHeight: '', - getHeight: () => '100px', - database: {}, - defaultQueryLimit: 1000, - maxRow: 100000, - }; - - const getWrapper = () => - shallow() - .dive() - .dive(); - - const mountWithAct = async () => - act(async () => { - mount( - - - , - { - wrappingComponent: ThemeProvider, - wrappingComponentProps: { theme: supersetTheme }, - }, - ); - }); - const setup = (props = {}, overridesStore) => - render(, { - useRedux: true, - store: overridesStore || store, - }); - - let wrapper; - it('is valid', () => { - expect(React.isValidElement()).toBe( - true, - ); - }); - describe('componentDidMount', () => { - let uriStub; - beforeEach(() => { - sinon.stub(window.history, 'replaceState'); - uriStub = sinon.stub(URI.prototype, 'search'); - }); - afterEach(() => { - window.history.replaceState.restore(); - uriStub.restore(); - }); - it('should handle id', async () => { - uriStub.returns({ id: 1 }); - await mountWithAct(); - expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab'); - }); - it('should handle savedQueryId', async () => { - uriStub.returns({ savedQueryId: 1 }); - await mountWithAct(); - expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab'); - }); - it('should handle sql', async () => { - uriStub.returns({ sql: 1, dbid: 1 }); - await mountWithAct(); - expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab'); - }); - it('should handle custom url params', async () => { - uriStub.returns({ - sql: 1, - dbid: 1, - custom_value: 'str', - extra_attr1: 'true', - }); - await mountWithAct(); - expect(window.history.replaceState.getCall(0).args[2]).toBe( - '/sqllab?custom_value=str&extra_attr1=true', - ); - }); - }); - it('should removeQueryEditor', () => { - wrapper = getWrapper(); - sinon.stub(wrapper.instance().props.actions, 'removeQueryEditor'); - - wrapper.instance().removeQueryEditor(queryEditors[0]); - expect( - wrapper.instance().props.actions.removeQueryEditor.getCall(0).args[0], - ).toBe(queryEditors[0]); - }); - it('should add new query editor', async () => { - const { getAllByLabelText } = setup(mockedProps); - fireEvent.click(getAllByLabelText('Add tab')[0]); - const actions = store.getActions(); - await waitFor(() => - expect(actions).toContainEqual({ - type: 'ADD_QUERY_EDITOR', - queryEditor: expect.objectContaining({ - name: expect.stringMatching(/Untitled Query (\d+)+/), - }), - }), - ); - }); - it('should properly increment query tab name', async () => { - const { getAllByLabelText } = setup(mockedProps); - const newTitle = newQueryTabName(store.getState().sqlLab.queryEditors); - fireEvent.click(getAllByLabelText('Add tab')[0]); - const actions = store.getActions(); - await waitFor(() => - expect(actions).toContainEqual({ - type: 'ADD_QUERY_EDITOR', - queryEditor: expect.objectContaining({ - name: newTitle, - }), - }), - ); - }); - it('should duplicate query editor', () => { - wrapper = getWrapper(); - sinon.stub(wrapper.instance().props.actions, 'cloneQueryToNewTab'); - - wrapper.instance().duplicateQueryEditor(queryEditors[0]); - expect( - wrapper.instance().props.actions.cloneQueryToNewTab.getCall(0).args[0], - ).toBe(queryEditors[0]); - }); - it('should handle select', () => { - const mockEvent = { - target: { - getAttribute: () => null, - }, - }; - wrapper = getWrapper(); - sinon.stub(wrapper.instance().props.actions, 'switchQueryEditor'); - - // cannot switch to current tab, switchQueryEditor never gets called - wrapper.instance().handleSelect('dfsadfs', mockEvent); - expect( - wrapper.instance().props.actions.switchQueryEditor.callCount, - ).toEqual(0); - }); - it('should handle add tab', () => { - wrapper = getWrapper(); - sinon.spy(wrapper.instance(), 'newQueryEditor'); - - wrapper.instance().handleEdit('1', 'add'); - expect(wrapper.instance().newQueryEditor.callCount).toBe(1); - wrapper.instance().newQueryEditor.restore(); - }); - it('should render', () => { - wrapper = getWrapper(); - wrapper.setState({ hideLeftBar: true }); - - const firstTab = wrapper.find(EditableTabs.TabPane).first(); - expect(firstTab.props()['data-key']).toContain( - initialState.sqlLab.queryEditors[0].id, - ); - expect(firstTab.find(SqlEditor)).toHaveLength(1); - }); - it('should disable new tab when offline', () => { - wrapper = getWrapper(); - expect(wrapper.find('#a11y-query-editor-tabs').props().hideAdd).toBe(false); - wrapper.setProps({ offline: true }); - expect(wrapper.find('#a11y-query-editor-tabs').props().hideAdd).toBe(true); - }); - it('should have an empty state when query editors is empty', () => { - wrapper = getWrapper(); - wrapper.setProps({ queryEditors: [] }); - const firstTab = wrapper.find(EditableTabs.TabPane).first(); - expect(firstTab.props()['data-key']).toBe(0); - }); -}); diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.tsx new file mode 100644 index 0000000000..6b048830e8 --- /dev/null +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.tsx @@ -0,0 +1,178 @@ +/** + * 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 React from 'react'; +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import URI from 'urijs'; +import { fireEvent, render, waitFor } from 'spec/helpers/testing-library'; +import fetchMock from 'fetch-mock'; +import TabbedSqlEditors from 'src/SqlLab/components/TabbedSqlEditors'; +import { initialState } from 'src/SqlLab/fixtures'; +import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName'; +import { Store } from 'redux'; +import { RootState } from 'src/views/store'; +import { SET_ACTIVE_QUERY_EDITOR } from 'src/SqlLab/actions/sqlLab'; + +fetchMock.get('glob:*/api/v1/database/*', {}); +fetchMock.get('glob:*/api/v1/saved_query/*', {}); +fetchMock.get('glob:*/kv/*', {}); + +jest.mock('src/SqlLab/components/SqlEditor', () => () => ( +
+)); + +const middlewares = [thunk]; +const mockStore = configureStore(middlewares); +const store = mockStore(initialState); + +const setup = (overridesStore?: Store, initialState?: RootState) => + render(, { + useRedux: true, + initialState, + ...(overridesStore && { store: overridesStore }), + }); + +beforeEach(() => { + store.clearActions(); +}); + +describe('componentDidMount', () => { + let uriStub = jest.spyOn(URI.prototype, 'search'); + let replaceState = jest.spyOn(window.history, 'replaceState'); + beforeEach(() => { + replaceState = jest.spyOn(window.history, 'replaceState'); + uriStub = jest.spyOn(URI.prototype, 'search'); + }); + afterEach(() => { + replaceState.mockReset(); + uriStub.mockReset(); + }); + test('should handle id', () => { + uriStub.mockReturnValue({ id: 1 }); + setup(store); + expect(replaceState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + '/sqllab', + ); + }); + test('should handle savedQueryId', () => { + uriStub.mockReturnValue({ savedQueryId: 1 }); + setup(store); + expect(replaceState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + '/sqllab', + ); + }); + test('should handle sql', () => { + uriStub.mockReturnValue({ sql: 1, dbid: 1 }); + setup(store); + expect(replaceState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + '/sqllab', + ); + }); + test('should handle custom url params', () => { + uriStub.mockReturnValue({ + sql: 1, + dbid: 1, + custom_value: 'str', + extra_attr1: 'true', + }); + setup(store); + expect(replaceState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + '/sqllab?custom_value=str&extra_attr1=true', + ); + }); +}); + +test('should removeQueryEditor', async () => { + const { getByRole, getAllByRole, queryByText } = setup( + undefined, + initialState, + ); + const tabCount = getAllByRole('tab').length; + const tabList = getByRole('tablist'); + const closeButton = tabList.getElementsByTagName('button')[0]; + expect(closeButton).toBeInTheDocument(); + if (closeButton) { + fireEvent.click(closeButton); + } + await waitFor(() => expect(getAllByRole('tab').length).toEqual(tabCount - 1)); + expect(queryByText(initialState.sqlLab.queryEditors[0].name)).toBeFalsy(); +}); +test('should add new query editor', async () => { + const { getAllByLabelText, getAllByRole } = setup(undefined, initialState); + const tabCount = getAllByRole('tab').length; + fireEvent.click(getAllByLabelText('Add tab')[0]); + await waitFor(() => expect(getAllByRole('tab').length).toEqual(tabCount + 1)); + expect(getAllByRole('tab')[tabCount]).toHaveTextContent( + /Untitled Query (\d+)+/, + ); +}); +test('should properly increment query tab name', async () => { + const { getAllByLabelText, getAllByRole } = setup(undefined, initialState); + const tabCount = getAllByRole('tab').length; + const newTitle = newQueryTabName(initialState.sqlLab.queryEditors); + fireEvent.click(getAllByLabelText('Add tab')[0]); + await waitFor(() => expect(getAllByRole('tab').length).toEqual(tabCount + 1)); + expect(getAllByRole('tab')[tabCount]).toHaveTextContent(newTitle); +}); +test('should handle select', async () => { + const { getAllByRole } = setup(store); + const tabs = getAllByRole('tab'); + fireEvent.click(tabs[1]); + await waitFor(() => expect(store.getActions()).toHaveLength(1)); + expect(store.getActions()[0]).toEqual( + expect.objectContaining({ + type: SET_ACTIVE_QUERY_EDITOR, + queryEditor: initialState.sqlLab.queryEditors[1], + }), + ); +}); +test('should render', () => { + const { getAllByRole } = setup(store); + const tabs = getAllByRole('tab'); + expect(tabs).toHaveLength(initialState.sqlLab.queryEditors.length); +}); +test('should disable new tab when offline', () => { + const { queryAllByLabelText } = setup(undefined, { + ...initialState, + sqlLab: { + ...initialState.sqlLab, + offline: true, + }, + }); + expect(queryAllByLabelText('Add tab').length).toEqual(0); +}); +test('should have an empty state when query editors is empty', () => { + const { getByText } = setup(undefined, { + ...initialState, + sqlLab: { + ...initialState.sqlLab, + queryEditors: [], + tabHistory: [], + }, + }); + expect(getByText('Add a new tab to create SQL Query')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx similarity index 83% rename from superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx rename to superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx index 0bd60fff7e..62ecfb5dcc 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx @@ -18,11 +18,10 @@ */ import React from 'react'; import { pick } from 'lodash'; -import PropTypes from 'prop-types'; import { EditableTabs } from 'src/components/Tabs'; import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; import URI from 'urijs'; +import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types'; import { FeatureFlag, styled, t, isFeatureEnabled } from '@superset-ui/core'; import { Tooltip } from 'src/components/Tooltip'; import { detectOS } from 'src/utils/common'; @@ -33,22 +32,7 @@ import { locationContext } from 'src/pages/SqlLab/LocationContext'; import SqlEditor from '../SqlEditor'; import SqlEditorTabHeader from '../SqlEditorTabHeader'; -const propTypes = { - actions: PropTypes.object.isRequired, - defaultDbId: PropTypes.number, - displayLimit: PropTypes.number, - defaultQueryLimit: PropTypes.number.isRequired, - maxRow: PropTypes.number.isRequired, - databases: PropTypes.object.isRequired, - queries: PropTypes.object.isRequired, - queryEditors: PropTypes.array, - tabHistory: PropTypes.array.isRequired, - tables: PropTypes.array.isRequired, - offline: PropTypes.bool, - saveQueryWarning: PropTypes.string, - scheduleQueryWarning: PropTypes.string, -}; -const defaultProps = { +const DEFAULT_PROPS = { queryEditors: [], offline: false, saveQueryWarning: null, @@ -73,15 +57,14 @@ const TabTitle = styled.span` // Get the user's OS const userOS = detectOS(); -class TabbedSqlEditors extends React.PureComponent { - constructor(props) { +type TabbedSqlEditorsProps = ReturnType; + +const SQL_LAB_URL = '/sqllab'; + +class TabbedSqlEditors extends React.PureComponent { + constructor(props: TabbedSqlEditorsProps) { super(props); - const sqlLabUrl = '/sqllab'; - this.state = { - sqlLabUrl, - }; this.removeQueryEditor = this.removeQueryEditor.bind(this); - this.duplicateQueryEditor = this.duplicateQueryEditor.bind(this); this.handleSelect = this.handleSelect.bind(this); this.handleEdit = this.handleEdit.bind(this); } @@ -136,7 +119,7 @@ class TabbedSqlEditors extends React.PureComponent { ...this.context.requestedQuery, ...bootstrapData.requested_query, ...queryParameters, - }; + } as Record; // Popping a new tab based on the querystring if (id || sql || savedQueryId || datasourceKey || queryId) { @@ -149,7 +132,7 @@ class TabbedSqlEditors extends React.PureComponent { } else if (datasourceKey) { this.props.actions.popDatasourceQuery(datasourceKey, sql); } else if (sql) { - let databaseId = dbid; + let databaseId: string | number = dbid; if (databaseId) { databaseId = parseInt(databaseId, 10); } else { @@ -177,11 +160,11 @@ class TabbedSqlEditors extends React.PureComponent { this.newQueryEditor(); if (isNewQuery) { - window.history.replaceState({}, document.title, this.state.sqlLabUrl); + window.history.replaceState({}, document.title, SQL_LAB_URL); } } else { const qe = this.activeQueryEditor(); - const latestQuery = this.props.queries[qe.latestQueryId]; + const latestQuery = this.props.queries[qe?.latestQueryId || '']; if ( isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) && latestQuery && @@ -197,9 +180,9 @@ class TabbedSqlEditors extends React.PureComponent { } } - popNewTab(urlParams) { + popNewTab(urlParams: Record) { // Clean the url in browser history - const updatedUrl = `${URI(this.state.sqlLabUrl).query(urlParams)}`; + const updatedUrl = `${URI(SQL_LAB_URL).query(urlParams)}`; window.history.replaceState({}, document.title, updatedUrl); } @@ -215,7 +198,7 @@ class TabbedSqlEditors extends React.PureComponent { this.props.actions.addNewQueryEditor(); } - handleSelect(key) { + handleSelect(key: string) { const qeid = this.props.tabHistory[this.props.tabHistory.length - 1]; if (key !== qeid) { const queryEditor = this.props.queryEditors.find(qe => qe.id === key); @@ -229,24 +212,22 @@ class TabbedSqlEditors extends React.PureComponent { } } - handleEdit(key, action) { + handleEdit(key: string, action: string) { if (action === 'remove') { const qe = this.props.queryEditors.find(qe => qe.id === key); - this.removeQueryEditor(qe); + if (qe) { + this.removeQueryEditor(qe); + } } if (action === 'add') { this.newQueryEditor(); } } - removeQueryEditor(qe) { + removeQueryEditor(qe: QueryEditor) { this.props.actions.removeQueryEditor(qe); } - duplicateQueryEditor(qe) { - this.props.actions.cloneQueryToNewTab(qe, false); - } - render() { const noQueryEditors = this.props.queryEditors?.length === 0; const editors = this.props.queryEditors?.map(qe => ( @@ -257,7 +238,6 @@ class TabbedSqlEditors extends React.PureComponent { data-key={qe.id} > xt.queryEditorId === qe.id)} queryEditor={qe} defaultQueryLimit={this.props.defaultQueryLimit} maxRow={this.props.maxRow} @@ -332,30 +312,45 @@ class TabbedSqlEditors extends React.PureComponent { ); } } -TabbedSqlEditors.propTypes = propTypes; -TabbedSqlEditors.defaultProps = defaultProps; + TabbedSqlEditors.contextType = locationContext; -function mapStateToProps({ sqlLab, common }) { +export function mapStateToProps({ sqlLab, common }: SqlLabRootState) { return { databases: sqlLab.databases, - queryEditors: sqlLab.queryEditors, + queryEditors: sqlLab.queryEditors ?? DEFAULT_PROPS.queryEditors, queries: sqlLab.queries, tabHistory: sqlLab.tabHistory, tables: sqlLab.tables, defaultDbId: common.conf.SQLLAB_DEFAULT_DBID, displayLimit: common.conf.DISPLAY_MAX_ROW, - offline: sqlLab.offline, + offline: sqlLab.offline ?? DEFAULT_PROPS.offline, defaultQueryLimit: common.conf.DEFAULT_SQLLAB_LIMIT, maxRow: common.conf.SQL_MAX_ROW, - saveQueryWarning: common.conf.SQLLAB_SAVE_WARNING_MESSAGE, - scheduleQueryWarning: common.conf.SQLLAB_SCHEDULE_WARNING_MESSAGE, - }; -} -function mapDispatchToProps(dispatch) { - return { - actions: bindActionCreators(Actions, dispatch), + saveQueryWarning: + common.conf.SQLLAB_SAVE_WARNING_MESSAGE ?? DEFAULT_PROPS.saveQueryWarning, + scheduleQueryWarning: + common.conf.SQLLAB_SCHEDULE_WARNING_MESSAGE ?? + DEFAULT_PROPS.scheduleQueryWarning, }; } -export default connect(mapStateToProps, mapDispatchToProps)(TabbedSqlEditors); +const mapDispatchToProps = { + ...Actions, +}; + +function mergeProps( + stateProps: ReturnType, + dispatchProps: typeof mapDispatchToProps, +) { + return { + ...stateProps, + actions: dispatchProps, + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps, + mergeProps, +)(TabbedSqlEditors); diff --git a/superset-frontend/src/SqlLab/fixtures.ts b/superset-frontend/src/SqlLab/fixtures.ts index 24d512a1b3..845e2209b5 100644 --- a/superset-frontend/src/SqlLab/fixtures.ts +++ b/superset-frontend/src/SqlLab/fixtures.ts @@ -35,7 +35,7 @@ export const alert = { bsStyle: 'danger', msg: 'Ooops', id: 'lksvmcx32' }; export const table = { dbId: 1, selectStar: 'SELECT * FROM ab_user', - queryEditorId: 'rJ-KP47a', + queryEditorId: 'dfsadfs', schema: 'superset', name: 'ab_user', id: 'r11Vgt60', diff --git a/superset-frontend/src/SqlLab/types.ts b/superset-frontend/src/SqlLab/types.ts index 6b150c2ba5..cac9ceb5d9 100644 --- a/superset-frontend/src/SqlLab/types.ts +++ b/superset-frontend/src/SqlLab/types.ts @@ -16,11 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { JsonObject, QueryResponse } from '@superset-ui/core'; -import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { QueryResponse } from '@superset-ui/core'; +import { + CommonBootstrapData, + UserWithPermissionsAndRoles, +} from 'src/types/bootstrapTypes'; import { ToastType } from 'src/components/MessageToasts/types'; import { DropdownButtonProps } from 'src/components/DropdownButton'; import { ButtonProps } from 'src/components/Button'; +import type { TableMetaData } from 'src/hooks/apiResources'; export type QueryButtonProps = DropdownButtonProps | ButtonProps; @@ -81,8 +85,10 @@ export interface Table { name: string; queryEditorId: QueryEditor['id']; dataPreviewQueryId: string | null; - expanded?: boolean; + expanded: boolean; initialized?: boolean; + inLocalStorage?: boolean; + persistData?: TableMetaData; } export type SqlLabRootState = { @@ -92,7 +98,7 @@ export type SqlLabRootState = { databases: Record; dbConnect: boolean; offline: boolean; - queries: Record; + queries: Record; queryEditors: QueryEditor[]; tabHistory: string[]; // default is activeTab ? [activeTab.id.toString()] : [] tables: Table[]; @@ -105,10 +111,7 @@ export type SqlLabRootState = { localStorageUsageInKilobytes: number; messageToasts: toastState[]; user: UserWithPermissionsAndRoles; - common: { - flash_messages: string[]; - conf: JsonObject; - }; + common: CommonBootstrapData; }; export enum DatasetRadioState { diff --git a/superset-frontend/src/SqlLab/utils/emptyQueryResults.test.js b/superset-frontend/src/SqlLab/utils/emptyQueryResults.test.ts similarity index 94% rename from superset-frontend/src/SqlLab/utils/emptyQueryResults.test.js rename to superset-frontend/src/SqlLab/utils/emptyQueryResults.test.ts index f08fccbef7..ca7f60af20 100644 --- a/superset-frontend/src/SqlLab/utils/emptyQueryResults.test.js +++ b/superset-frontend/src/SqlLab/utils/emptyQueryResults.test.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import type { QueryResponse } from '@superset-ui/core'; import { emptyQueryResults, clearQueryEditors, @@ -43,7 +44,7 @@ describe('reduxStateToLocalStorageHelper', () => { expect(Date.now() - startDttm).toBeGreaterThan( LOCALSTORAGE_MAX_QUERY_AGE_MS, ); - expect(Object.keys(oldQuery.results)).toContain('data'); + expect(Object.keys(oldQuery.results || {})).toContain('data'); const emptiedQuery = emptyQueryResults(queriesObj); expect(emptiedQuery[id].startDttm).toBe(startDttm); @@ -55,7 +56,7 @@ describe('reduxStateToLocalStorageHelper', () => { ...queries[0], startDttm: Date.now(), results: { data: [{ a: 1 }] }, - }; + } as unknown as QueryResponse; const largeQuery = { ...queries[1], startDttm: Date.now(), @@ -70,7 +71,7 @@ describe('reduxStateToLocalStorageHelper', () => { }, ], }, - }; + } as unknown as QueryResponse; expect(Object.keys(largeQuery.results)).toContain('data'); const emptiedQuery = emptyQueryResults({ [reasonableSizeQuery.id]: reasonableSizeQuery, diff --git a/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.js b/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.ts similarity index 83% rename from superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.js rename to superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.ts index 5b7a31b304..8b7f41f9f7 100644 --- a/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.js +++ b/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.ts @@ -16,6 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +import type { QueryResponse } from '@superset-ui/core'; +import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types'; +import type { ThunkDispatch } from 'redux-thunk'; import { pick } from 'lodash'; import { tableApiUtil } from 'src/hooks/apiResources/tables'; import { @@ -44,7 +47,7 @@ const PERSISTENT_QUERY_EDITOR_KEYS = new Set([ 'hideLeftBar', ]); -function shouldEmptyQueryResults(query) { +function shouldEmptyQueryResults(query: QueryResponse) { const { startDttm, results } = query; return ( Date.now() - startDttm > LOCALSTORAGE_MAX_QUERY_AGE_MS || @@ -53,7 +56,7 @@ function shouldEmptyQueryResults(query) { ); } -export function emptyTablePersistData(tables) { +export function emptyTablePersistData(tables: Table[]) { return tables .map(table => pick(table, [ @@ -68,7 +71,9 @@ export function emptyTablePersistData(tables) { .filter(({ queryEditorId }) => Boolean(queryEditorId)); } -export function emptyQueryResults(queries) { +export function emptyQueryResults( + queries: SqlLabRootState['sqlLab']['queries'], +) { return Object.keys(queries).reduce((accu, key) => { const { results } = queries[key]; const query = { @@ -84,7 +89,7 @@ export function emptyQueryResults(queries) { }, {}); } -export function clearQueryEditors(queryEditors) { +export function clearQueryEditors(queryEditors: QueryEditor[]) { return queryEditors.map(editor => // only return selected keys Object.keys(editor) @@ -99,7 +104,10 @@ export function clearQueryEditors(queryEditors) { ); } -export function rehydratePersistedState(dispatch, state) { +export function rehydratePersistedState( + dispatch: ThunkDispatch, + state: SqlLabRootState, +) { // Rehydrate server side persisted table metadata state.sqlLab.tables.forEach(({ name: table, schema, dbId, persistData }) => { if (dbId && schema && table && persistData?.columns) { diff --git a/superset-frontend/src/components/EmptyState/index.tsx b/superset-frontend/src/components/EmptyState/index.tsx index 95c454b0ae..9a3e22cf1b 100644 --- a/superset-frontend/src/components/EmptyState/index.tsx +++ b/superset-frontend/src/components/EmptyState/index.tsx @@ -29,7 +29,7 @@ export enum EmptyStateSize { } export interface EmptyStateSmallProps { - title: ReactNode; + title?: ReactNode; description?: ReactNode; image?: ReactNode; } diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts index 80570f5d22..332acf0e82 100644 --- a/superset-frontend/src/types/bootstrapTypes.ts +++ b/superset-frontend/src/types/bootstrapTypes.ts @@ -160,6 +160,7 @@ export interface BootstrapData { embedded?: { dashboard_id: string; }; + requested_query?: JsonObject; } export function isUser(user: any): user is User {