fix(sqllab): flaky json explore modal due to over-rendering (#26156)

This commit is contained in:
JUST.in DO IT 2023-12-07 09:28:59 -08:00 committed by GitHub
parent 39c6488463
commit f30f685eb5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 679 additions and 278 deletions

View File

@ -45,7 +45,7 @@ export interface QueryAutoRefreshProps {
// returns true if the Query.state matches one of the specifc values indicating the query is still processing on server
export const isQueryRunning = (q: Query): boolean =>
runningQueryStateList.includes(q?.state);
runningQueryStateList.includes(q?.state) && !q?.resultsKey;
// returns true if at least one query is running and within the max age to poll timeframe
export const shouldCheckForQueries = (queryList: QueryDictionary): boolean => {

View File

@ -19,9 +19,10 @@
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import QueryHistory from 'src/SqlLab/components/QueryHistory';
import { initialState } from 'src/SqlLab/fixtures';
const mockedProps = {
queries: [],
queryEditorId: 123,
displayLimit: 1000,
latestQueryId: 'yhMUZCGb',
};
@ -32,7 +33,7 @@ const setup = (overrides = {}) => (
describe('QueryHistory', () => {
it('Renders an empty state for query history', () => {
render(setup());
render(setup(), { useRedux: true, initialState });
const emptyStateText = screen.getByText(
/run a query to display query history/i,

View File

@ -16,13 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { EmptyStateMedium } from 'src/components/EmptyState';
import { t, styled, QueryResponse } from '@superset-ui/core';
import { t, styled } from '@superset-ui/core';
import QueryTable from 'src/SqlLab/components/QueryTable';
import { SqlLabRootState } from 'src/SqlLab/types';
interface QueryHistoryProps {
queries: QueryResponse[];
queryEditorId: string | number;
displayLimit: number;
latestQueryId: string | undefined;
}
@ -39,11 +41,23 @@ const StyledEmptyStateWrapper = styled.div`
`;
const QueryHistory = ({
queries,
queryEditorId,
displayLimit,
latestQueryId,
}: QueryHistoryProps) =>
queries.length > 0 ? (
}: QueryHistoryProps) => {
const queries = useSelector(
({ sqlLab: { queries } }: SqlLabRootState) => queries,
shallowEqual,
);
const editorQueries = useMemo(
() =>
Object.values(queries).filter(
({ sqlEditorId }) => String(sqlEditorId) === String(queryEditorId),
),
[queries, queryEditorId],
);
return editorQueries.length > 0 ? (
<QueryTable
columns={[
'state',
@ -55,7 +69,7 @@ const QueryHistory = ({
'results',
'actions',
]}
queries={queries}
queries={editorQueries}
displayLimit={displayLimit}
latestQueryId={latestQueryId}
/>
@ -67,5 +81,6 @@ const QueryHistory = ({
/>
</StyledEmptyStateWrapper>
);
};
export default QueryHistory;

View File

@ -251,8 +251,7 @@ const QueryTable = ({
modalBody={
<ResultSet
showSql
user={user}
query={query}
queryId={query.id}
height={400}
displayLimit={displayLimit}
defaultQueryLimit={1000}

View File

@ -37,65 +37,91 @@ import {
const mockedProps = {
cache: true,
query: queries[0],
queryId: queries[0].id,
height: 140,
database: { allows_virtual_table_explore: true },
user,
displayLimit: 1000,
defaultQueryLimit: 1000,
};
const stoppedQueryProps = { ...mockedProps, query: stoppedQuery };
const runningQueryProps = { ...mockedProps, query: runningQuery };
const fetchingQueryProps = {
...mockedProps,
query: {
dbId: 1,
cached: false,
ctas: false,
id: 'ryhHUZCGb',
progress: 100,
state: 'fetching',
startDttm: Date.now() - 500,
},
};
const cachedQueryProps = { ...mockedProps, query: cachedQuery };
const failedQueryWithErrorMessageProps = {
...mockedProps,
query: failedQueryWithErrorMessage,
};
const failedQueryWithErrorsProps = {
...mockedProps,
query: failedQueryWithErrors,
};
const newProps = {
query: {
cached: false,
resultsKey: 'new key',
results: {
data: [{ a: 1 }],
const stoppedQueryState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
[stoppedQuery.id]: stoppedQuery,
},
},
};
const runningQueryState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
[runningQuery.id]: runningQuery,
},
},
};
const fetchingQueryState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
[mockedProps.queryId]: {
dbId: 1,
cached: false,
ctas: false,
id: 'ryhHUZCGb',
progress: 100,
state: 'fetching',
startDttm: Date.now() - 500,
},
},
},
};
const cachedQueryState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
[cachedQuery.id]: cachedQuery,
},
},
};
const failedQueryWithErrorMessageState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
[failedQueryWithErrorMessage.id]: failedQueryWithErrorMessage,
},
},
};
const failedQueryWithErrorsState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
[failedQueryWithErrors.id]: failedQueryWithErrors,
},
},
};
const newProps = {
displayLimit: 1001,
};
const asyncQueryProps = {
...mockedProps,
database: { allow_run_async: true },
};
const asyncRefetchDataPreviewProps = {
...asyncQueryProps,
query: {
state: 'success',
results: undefined,
isDataPreview: true,
},
};
const asyncRefetchResultsTableProps = {
...asyncQueryProps,
query: {
state: 'success',
results: undefined,
resultsKey: 'async results key',
},
};
const reRunQueryEndpoint = 'glob:*/api/v1/sqllab/execute/';
fetchMock.get('glob:*/api/v1/dataset/?*', { result: [] });
fetchMock.post(reRunQueryEndpoint, { result: [] });
fetchMock.get('glob:*/api/v1/sqllab/results/*', { result: [] });
beforeEach(() => {
fetchMock.resetHistory();
});
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
@ -107,25 +133,47 @@ const setup = (props?: any, store?: Store) =>
describe('ResultSet', () => {
test('renders a Table', async () => {
const { getByTestId } = setup(mockedProps, mockStore(initialState));
const { getByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
const table = getByTestId('table-container');
expect(table).toBeInTheDocument();
});
test('should render success query', async () => {
const query = queries[0];
const { queryAllByText, getByTestId } = setup(
mockedProps,
mockStore(initialState),
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[query.id]: query,
},
},
}),
);
const table = getByTestId('table-container');
expect(table).toBeInTheDocument();
const firstColumn = queryAllByText(
mockedProps.query.results?.columns[0].column_name ?? '',
query.results?.columns[0].column_name ?? '',
)[0];
const secondColumn = queryAllByText(
mockedProps.query.results?.columns[1].column_name ?? '',
query.results?.columns[1].column_name ?? '',
)[0];
expect(firstColumn).toBeInTheDocument();
expect(secondColumn).toBeInTheDocument();
@ -135,12 +183,24 @@ describe('ResultSet', () => {
});
test('should render empty results', async () => {
const props = {
...mockedProps,
query: { ...mockedProps.query, results: { data: [] } },
const query = {
...queries[0],
results: { data: [] },
};
await waitFor(() => {
setup(props, mockStore(initialState));
setup(
mockedProps,
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[query.id]: query,
},
},
}),
);
});
const alert = screen.getByRole('alert');
@ -149,42 +209,70 @@ describe('ResultSet', () => {
});
test('should call reRunQuery if timed out', async () => {
const store = mockStore(initialState);
const propsWithError = {
...mockedProps,
query: { ...queries[0], errorMessage: 'Your session timed out' },
const query = {
...queries[0],
errorMessage: 'Your session timed out',
};
const store = mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[query.id]: query,
},
},
});
setup(propsWithError, store);
expect(fetchMock.calls(reRunQueryEndpoint)).toHaveLength(0);
setup(mockedProps, store);
expect(store.getActions()).toHaveLength(1);
expect(store.getActions()[0].query.errorMessage).toEqual(
'Your session timed out',
);
expect(store.getActions()[0].type).toEqual('START_QUERY');
await waitFor(() =>
expect(fetchMock.calls(reRunQueryEndpoint)).toHaveLength(1),
);
});
test('should not call reRunQuery if no error', async () => {
const store = mockStore(initialState);
const query = queries[0];
const store = mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[query.id]: query,
},
},
});
setup(mockedProps, store);
expect(store.getActions()).toEqual([]);
expect(fetchMock.calls(reRunQueryEndpoint)).toHaveLength(0);
});
test('should render cached query', async () => {
const store = mockStore(initialState);
const { rerender } = setup(cachedQueryProps, store);
const store = mockStore(cachedQueryState);
const { rerender } = setup(
{ ...mockedProps, queryId: cachedQuery.id },
store,
);
// @ts-ignore
rerender(<ResultSet {...newProps} />);
expect(store.getActions()).toHaveLength(2);
expect(store.getActions()[0].query.results).toEqual(
cachedQueryProps.query.results,
);
rerender(<ResultSet {...mockedProps} {...newProps} />);
expect(store.getActions()).toHaveLength(1);
expect(store.getActions()[0].query.results).toEqual(cachedQuery.results);
expect(store.getActions()[0].type).toEqual('CLEAR_QUERY_RESULTS');
});
test('should render stopped query', async () => {
await waitFor(() => {
setup(stoppedQueryProps, mockStore(initialState));
setup(
{ ...mockedProps, queryId: stoppedQuery.id },
mockStore(stoppedQueryState),
);
});
const alert = screen.getByRole('alert');
@ -192,15 +280,18 @@ describe('ResultSet', () => {
});
test('should render running/pending/fetching query', async () => {
const { getByTestId } = setup(runningQueryProps, mockStore(initialState));
const { getByTestId } = setup(
{ ...mockedProps, queryId: runningQuery.id },
mockStore(runningQueryState),
);
const progressBar = getByTestId('progress-bar');
expect(progressBar).toBeInTheDocument();
});
test('should render fetching w/ 100 progress query', async () => {
const { getByRole, getByText } = setup(
fetchingQueryProps,
mockStore(initialState),
mockedProps,
mockStore(fetchingQueryState),
);
const loading = getByRole('status');
expect(loading).toBeInTheDocument();
@ -209,7 +300,10 @@ describe('ResultSet', () => {
test('should render a failed query with an error message', async () => {
await waitFor(() => {
setup(failedQueryWithErrorMessageProps, mockStore(initialState));
setup(
{ ...mockedProps, queryId: failedQueryWithErrorMessage.id },
mockStore(failedQueryWithErrorMessageState),
);
});
expect(screen.getByText('Database error')).toBeInTheDocument();
@ -218,44 +312,129 @@ describe('ResultSet', () => {
test('should render a failed query with an errors object', async () => {
await waitFor(() => {
setup(failedQueryWithErrorsProps, mockStore(initialState));
setup(
{ ...mockedProps, queryId: failedQueryWithErrors.id },
mockStore(failedQueryWithErrorsState),
);
});
expect(screen.getByText('Database error')).toBeInTheDocument();
});
test('renders if there is no limit in query.results but has queryLimit', async () => {
const query = {
...queries[0],
};
await waitFor(() => {
setup(
mockedProps,
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[query.id]: query,
},
},
}),
);
});
const { getByRole } = setup(mockedProps, mockStore(initialState));
expect(getByRole('table')).toBeInTheDocument();
});
test('renders if there is a limit in query.results but not queryLimit', async () => {
const props = { ...mockedProps, query: queryWithNoQueryLimit };
const { getByRole } = setup(props, mockStore(initialState));
const props = { ...mockedProps, queryId: queryWithNoQueryLimit.id };
const { getByRole } = setup(
props,
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[queryWithNoQueryLimit.id]: queryWithNoQueryLimit,
},
},
}),
);
expect(getByRole('table')).toBeInTheDocument();
});
test('Async queries - renders "Fetch data preview" button when data preview has no results', () => {
setup(asyncRefetchDataPreviewProps, mockStore(initialState));
const asyncRefetchDataPreviewQuery = {
...queries[0],
state: 'success',
results: undefined,
isDataPreview: true,
};
setup(
{ ...asyncQueryProps, queryId: asyncRefetchDataPreviewQuery.id },
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[asyncRefetchDataPreviewQuery.id]: asyncRefetchDataPreviewQuery,
},
},
}),
);
expect(
screen.getByRole('button', {
name: /fetch data preview/i,
}),
).toBeVisible();
expect(screen.queryByRole('grid')).toBe(null);
expect(screen.queryByRole('table')).toBe(null);
});
test('Async queries - renders "Refetch results" button when a query has no results', () => {
setup(asyncRefetchResultsTableProps, mockStore(initialState));
const asyncRefetchResultsTableQuery = {
...queries[0],
state: 'success',
results: undefined,
resultsKey: 'async results key',
};
setup(
{ ...asyncQueryProps, queryId: asyncRefetchResultsTableQuery.id },
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[asyncRefetchResultsTableQuery.id]: asyncRefetchResultsTableQuery,
},
},
}),
);
expect(
screen.getByRole('button', {
name: /refetch results/i,
}),
).toBeVisible();
expect(screen.queryByRole('grid')).toBe(null);
expect(screen.queryByRole('table')).toBe(null);
});
test('Async queries - renders on the first call', () => {
setup(asyncQueryProps, mockStore(initialState));
const query = {
...queries[0],
};
setup(
{ ...asyncQueryProps, queryId: query.id },
mockStore({
...initialState,
user,
sqlLab: {
...initialState.sqlLab,
queries: {
[query.id]: query,
},
},
}),
);
expect(screen.getByRole('table')).toBeVisible();
expect(
screen.queryByRole('button', {

View File

@ -17,14 +17,14 @@
* under the License.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import pick from 'lodash/pick';
import ButtonGroup from 'src/components/ButtonGroup';
import Alert from 'src/components/Alert';
import Button from 'src/components/Button';
import shortid from 'shortid';
import {
QueryResponse,
QueryState,
styled,
t,
@ -41,8 +41,7 @@ import {
ISimpleColumn,
SaveDatasetModal,
} from 'src/SqlLab/components/SaveDatasetModal';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { EXPLORE_CHART_DEFAULT } from 'src/SqlLab/types';
import { EXPLORE_CHART_DEFAULT, SqlLabRootState } from 'src/SqlLab/types';
import { mountExploreUrl } from 'src/explore/exploreUtils';
import { postFormData } from 'src/explore/exploreUtils/formData';
import ProgressBar from 'src/components/ProgressBar';
@ -82,12 +81,11 @@ export interface ResultSetProps {
database?: Record<string, any>;
displayLimit: number;
height: number;
query: QueryResponse;
queryId: string;
search?: boolean;
showSql?: boolean;
showSqlInline?: boolean;
visualize?: boolean;
user: UserWithPermissionsAndRoles;
defaultQueryLimit: number;
}
@ -145,14 +143,44 @@ const ResultSet = ({
database = {},
displayLimit,
height,
query,
queryId,
search = true,
showSql = false,
showSqlInline = false,
visualize = true,
user,
defaultQueryLimit,
}: ResultSetProps) => {
const user = useSelector(({ user }: SqlLabRootState) => user, shallowEqual);
const query = useSelector(
({ sqlLab: { queries } }: SqlLabRootState) =>
pick(queries[queryId], [
'id',
'errorMessage',
'cached',
'results',
'resultsKey',
'dbId',
'tab',
'sql',
'templateParams',
'schema',
'rows',
'queryLimit',
'limitingFactor',
'trackingUrl',
'state',
'errors',
'link',
'ctas',
'ctas_method',
'tempSchema',
'tempTable',
'isDataPreview',
'progress',
'extra',
]),
shallowEqual,
);
const ResultTable =
extensionsRegistry.get('sqleditor.extension.resultTable') ??
FilterableTable;
@ -179,8 +207,8 @@ const ResultSet = ({
reRunQueryIfSessionTimeoutErrorOnMount();
}, [reRunQueryIfSessionTimeoutErrorOnMount]);
const fetchResults = (query: QueryResponse) => {
dispatch(fetchQueryResults(query, displayLimit));
const fetchResults = (q: typeof query) => {
dispatch(fetchQueryResults(q, displayLimit));
};
const prevQuery = usePrevious(query);
@ -479,7 +507,7 @@ const ResultSet = ({
<ResultlessStyles>
<ErrorMessageWithStackTrace
title={t('Database error')}
error={query?.errors?.[0]}
error={query?.extra?.errors?.[0] || query?.errors?.[0]}
subtitle={<MonospaceDiv>{query.errorMessage}</MonospaceDiv>}
copyText={query.errorMessage || undefined}
link={query.link}
@ -662,4 +690,4 @@ const ResultSet = ({
);
};
export default ResultSet;
export default React.memo(ResultSet);

View File

@ -0,0 +1,135 @@
/**
* 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 { render } from 'spec/helpers/testing-library';
import { initialState, table, defaultQueryEditor } from 'src/SqlLab/fixtures';
import { denormalizeTimestamp } from '@superset-ui/core';
import { LOCALSTORAGE_MAX_QUERY_AGE_MS } from 'src/SqlLab/constants';
import Results from './Results';
const mockedProps = {
queryEditorId: defaultQueryEditor.id,
latestQueryId: 'LCly_kkIN',
height: 1,
displayLimit: 1,
defaultQueryLimit: 100,
};
const mockedEmptyProps = {
queryEditorId: 'random_id',
latestQueryId: 'empty_query_id',
height: 100,
displayLimit: 100,
defaultQueryLimit: 100,
};
const mockedExpiredProps = {
...mockedEmptyProps,
latestQueryId: 'expired_query_id',
};
const latestQueryProgressMsg = 'LATEST QUERY MESSAGE - LCly_kkIN';
const expireDateTime = Date.now() - LOCALSTORAGE_MAX_QUERY_AGE_MS - 1;
const mockState = {
...initialState,
sqlLab: {
...initialState,
offline: false,
tables: [
{
...table,
dataPreviewQueryId: '2g2_iRFMl',
queryEditorId: defaultQueryEditor.id,
},
],
databases: {},
queries: {
LCly_kkIN: {
cached: false,
changed_on: denormalizeTimestamp(new Date().toISOString()),
db: 'main',
dbId: 1,
id: 'LCly_kkIN',
startDttm: Date.now(),
sqlEditorId: defaultQueryEditor.id,
extra: { progress: latestQueryProgressMsg },
sql: 'select * from table1',
},
lXJa7F9_r: {
cached: false,
changed_on: denormalizeTimestamp(new Date(1559238500401).toISOString()),
db: 'main',
dbId: 1,
id: 'lXJa7F9_r',
startDttm: 1559238500401,
sqlEditorId: defaultQueryEditor.id,
sql: 'select * from table2',
},
'2g2_iRFMl': {
cached: false,
changed_on: denormalizeTimestamp(new Date(1559238506925).toISOString()),
db: 'main',
dbId: 1,
id: '2g2_iRFMl',
startDttm: 1559238506925,
sqlEditorId: defaultQueryEditor.id,
sql: 'select * from table3',
},
expired_query_id: {
cached: false,
changed_on: denormalizeTimestamp(
new Date(expireDateTime).toISOString(),
),
db: 'main',
dbId: 1,
id: 'expired_query_id',
startDttm: expireDateTime,
sqlEditorId: defaultQueryEditor.id,
sql: 'select * from table4',
},
},
},
};
test('Renders an empty state for results', async () => {
const { getByText } = render(<Results {...mockedEmptyProps} />, {
useRedux: true,
initialState: mockState,
});
const emptyStateText = getByText(/run a query to display results/i);
expect(emptyStateText).toBeVisible();
});
test('Renders an empty state for expired results', async () => {
const { getByText } = render(<Results {...mockedExpiredProps} />, {
useRedux: true,
initialState: mockState,
});
const emptyStateText = getByText(/run a query to display results/i);
expect(emptyStateText).toBeVisible();
});
test('should pass latest query down to ResultSet component', async () => {
const { getByText } = render(<Results {...mockedProps} />, {
useRedux: true,
initialState: mockState,
});
expect(getByText(latestQueryProgressMsg)).toBeVisible();
});

View File

@ -0,0 +1,106 @@
/**
* 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 { shallowEqual, useSelector } from 'react-redux';
import Alert from 'src/components/Alert';
import { EmptyStateMedium } from 'src/components/EmptyState';
import { FeatureFlag, styled, t, isFeatureEnabled } from '@superset-ui/core';
import { SqlLabRootState } from 'src/SqlLab/types';
import ResultSet from '../ResultSet';
import { LOCALSTORAGE_MAX_QUERY_AGE_MS } from '../../constants';
const EXTRA_HEIGHT_RESULTS = 8; // we need extra height in RESULTS tab. because the height from props was calculated based on PREVIEW tab.
type Props = {
latestQueryId: string;
height: number;
displayLimit: number;
defaultQueryLimit: number;
};
const StyledEmptyStateWrapper = styled.div`
height: 100%;
.ant-empty-image img {
margin-right: 28px;
}
p {
margin-right: 28px;
}
`;
const Results: React.FC<Props> = ({
latestQueryId,
height,
displayLimit,
defaultQueryLimit,
}) => {
const databases = useSelector(
({ sqlLab: { databases } }: SqlLabRootState) => databases,
shallowEqual,
);
const latestQuery = useSelector(
({ sqlLab: { queries } }: SqlLabRootState) => queries[latestQueryId || ''],
shallowEqual,
);
if (
!latestQuery ||
Date.now() - latestQuery.startDttm > LOCALSTORAGE_MAX_QUERY_AGE_MS
) {
return (
<StyledEmptyStateWrapper>
<EmptyStateMedium
title={t('Run a query to display results')}
image="document.svg"
/>
</StyledEmptyStateWrapper>
);
}
if (
isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) &&
latestQuery.state === 'success' &&
!latestQuery.resultsKey &&
!latestQuery.results
) {
return (
<Alert
type="warning"
message={t('No stored results found, you need to re-run your query')}
/>
);
}
return (
<ResultSet
search
queryId={latestQuery.id}
height={height + EXTRA_HEIGHT_RESULTS}
database={databases[latestQuery.dbId]}
displayLimit={displayLimit}
defaultQueryLimit={defaultQueryLimit}
showSql
showSqlInline
/>
);
};
export default Results;

View File

@ -17,15 +17,12 @@
* under the License.
*/
import React from 'react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import SouthPane, { SouthPaneProps } from 'src/SqlLab/components/SouthPane';
import { render } from 'spec/helpers/testing-library';
import SouthPane from 'src/SqlLab/components/SouthPane';
import '@testing-library/jest-dom/extend-expect';
import { STATUS_OPTIONS } from 'src/SqlLab/constants';
import { initialState, table, defaultQueryEditor } from 'src/SqlLab/fixtures';
import { denormalizeTimestamp } from '@superset-ui/core';
import { Store } from 'redux';
const mockedProps = {
queryEditorId: defaultQueryEditor.id,
@ -37,29 +34,32 @@ const mockedProps = {
const mockedEmptyProps = {
queryEditorId: 'random_id',
latestQueryId: '',
latestQueryId: 'empty_query_id',
height: 100,
displayLimit: 100,
defaultQueryLimit: 100,
};
jest.mock('src/SqlLab/components/SqlEditorLeftBar', () => jest.fn());
const latestQueryProgressMsg = 'LATEST QUERY MESSAGE - LCly_kkIN';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const store = mockStore({
const mockState = {
...initialState,
sqlLab: {
...initialState,
...initialState.sqlLab,
offline: false,
tables: [
{
...table,
name: 'table3',
dataPreviewQueryId: '2g2_iRFMl',
queryEditorId: defaultQueryEditor.id,
},
{
...table,
name: 'table4',
dataPreviewQueryId: 'erWdqEWPm',
queryEditorId: defaultQueryEditor.id,
},
],
databases: {},
queries: {
@ -72,6 +72,7 @@ const store = mockStore({
startDttm: Date.now(),
sqlEditorId: defaultQueryEditor.id,
extra: { progress: latestQueryProgressMsg },
sql: 'select * from table1',
},
lXJa7F9_r: {
cached: false,
@ -81,6 +82,7 @@ const store = mockStore({
id: 'lXJa7F9_r',
startDttm: 1559238500401,
sqlEditorId: defaultQueryEditor.id,
sql: 'select * from table2',
},
'2g2_iRFMl': {
cached: false,
@ -90,6 +92,7 @@ const store = mockStore({
id: '2g2_iRFMl',
startDttm: 1559238506925,
sqlEditorId: defaultQueryEditor.id,
sql: 'select * from table3',
},
erWdqEWPm: {
cached: false,
@ -99,44 +102,38 @@ const store = mockStore({
id: 'erWdqEWPm',
startDttm: 1559238516395,
sqlEditorId: defaultQueryEditor.id,
sql: 'select * from table4',
},
},
},
});
const setup = (props: SouthPaneProps, store: Store) =>
render(<SouthPane {...props} />, {
};
test('should render offline when the state is offline', async () => {
const { getByText } = render(<SouthPane {...mockedEmptyProps} />, {
useRedux: true,
...(store && { store }),
initialState: {
...initialState,
sqlLab: {
...initialState.sqlLab,
offline: true,
},
},
});
describe('SouthPane', () => {
const renderAndWait = (props: SouthPaneProps, store: Store) =>
waitFor(async () => setup(props, store));
expect(getByText(STATUS_OPTIONS.offline)).toBeVisible();
});
it('Renders an empty state for results', async () => {
await renderAndWait(mockedEmptyProps, store);
const emptyStateText = screen.getByText(/run a query to display results/i);
expect(emptyStateText).toBeVisible();
test('should render tabs for table preview queries', () => {
const { getAllByRole } = render(<SouthPane {...mockedProps} />, {
useRedux: true,
initialState: mockState,
});
it('should render offline when the state is offline', async () => {
await renderAndWait(
mockedEmptyProps,
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
offline: true,
},
}),
);
expect(screen.getByText(STATUS_OPTIONS.offline)).toBeVisible();
});
it('should pass latest query down to ResultSet component', async () => {
await renderAndWait(mockedProps, store);
expect(screen.getByText(latestQueryProgressMsg)).toBeVisible();
const tabs = getAllByRole('tab');
expect(tabs).toHaveLength(mockState.sqlLab.tables.length + 2);
expect(tabs[0]).toHaveTextContent('Results');
expect(tabs[1]).toHaveTextContent('Query history');
mockState.sqlLab.tables.forEach(({ name }, index) => {
expect(tabs[index + 2]).toHaveTextContent(`Preview: \`${name}\``);
});
});

View File

@ -19,10 +19,8 @@
import React, { createRef, useMemo } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import shortid from 'shortid';
import Alert from 'src/components/Alert';
import Tabs from 'src/components/Tabs';
import { EmptyStateMedium } from 'src/components/EmptyState';
import { FeatureFlag, styled, t, isFeatureEnabled } from '@superset-ui/core';
import { styled, t } from '@superset-ui/core';
import { setActiveSouthPaneTab } from 'src/SqlLab/actions/sqlLab';
@ -33,11 +31,11 @@ import ResultSet from '../ResultSet';
import {
STATUS_OPTIONS,
STATE_TYPE_MAP,
LOCALSTORAGE_MAX_QUERY_AGE_MS,
STATUS_OPTIONS_LOCALIZED,
} from '../../constants';
import Results from './Results';
const TAB_HEIGHT = 140;
const TAB_HEIGHT = 130;
/*
editorQueries are queries executed by users passed from SqlEditor component
@ -85,18 +83,6 @@ const StyledPane = styled.div<StyledPaneProps>`
}
`;
const EXTRA_HEIGHT_RESULTS = 24; // we need extra height in RESULTS tab. because the height from props was calculated based on PREVIEW tab.
const StyledEmptyStateWrapper = styled.div`
height: 100%;
.ant-empty-image img {
margin-right: 28px;
}
p {
margin-right: 28px;
}
`;
const SouthPane = ({
queryEditorId,
latestQueryId,
@ -105,128 +91,43 @@ const SouthPane = ({
defaultQueryLimit,
}: SouthPaneProps) => {
const dispatch = useDispatch();
const user = useSelector(({ user }: SqlLabRootState) => user, shallowEqual);
const { databases, offline, queries, tables } = useSelector(
({ sqlLab: { databases, offline, queries, tables } }: SqlLabRootState) => ({
databases,
const { offline, tables } = useSelector(
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
offline,
queries,
tables,
}),
shallowEqual,
);
const editorQueries = useMemo(
() =>
Object.values(queries).filter(
({ sqlEditorId }) => sqlEditorId === queryEditorId,
),
[queries, queryEditorId],
const queries = useSelector(
({ sqlLab: { queries } }: SqlLabRootState) => Object.keys(queries),
shallowEqual,
);
const dataPreviewQueries = useMemo(
() =>
tables
.filter(
({ dataPreviewQueryId, queryEditorId: qeId }) =>
dataPreviewQueryId &&
queryEditorId === qeId &&
queries[dataPreviewQueryId],
)
.map(({ name, dataPreviewQueryId }) => ({
...queries[dataPreviewQueryId || ''],
tableName: name,
})),
[queries, queryEditorId, tables],
);
const latestQuery = useMemo(
() => editorQueries.find(({ id }) => id === latestQueryId),
[editorQueries, latestQueryId],
);
const activeSouthPaneTab =
useSelector<SqlLabRootState, string>(
state => state.sqlLab.activeSouthPaneTab as string,
) ?? 'Results';
const querySet = useMemo(() => new Set(queries), [queries]);
const dataPreviewQueries = useMemo(
() =>
tables.filter(
({ dataPreviewQueryId, queryEditorId: qeId }) =>
dataPreviewQueryId &&
queryEditorId === qeId &&
querySet.has(dataPreviewQueryId),
),
[queryEditorId, tables, querySet],
);
const innerTabContentHeight = height - TAB_HEIGHT;
const southPaneRef = createRef<HTMLDivElement>();
const switchTab = (id: string) => {
dispatch(setActiveSouthPaneTab(id));
};
const renderOfflineStatus = () => (
return offline ? (
<Label className="m-r-3" type={STATE_TYPE_MAP[STATUS_OPTIONS.offline]}>
{STATUS_OPTIONS_LOCALIZED.offline}
</Label>
);
const renderResults = () => {
let results;
if (latestQuery) {
if (latestQuery?.extra?.errors) {
latestQuery.errors = latestQuery.extra.errors;
}
if (
isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) &&
latestQuery.state === 'success' &&
!latestQuery.resultsKey &&
!latestQuery.results
) {
results = (
<Alert
type="warning"
message={t(
'No stored results found, you need to re-run your query',
)}
/>
);
return results;
}
if (Date.now() - latestQuery.startDttm <= LOCALSTORAGE_MAX_QUERY_AGE_MS) {
results = (
<ResultSet
search
query={latestQuery}
user={user}
height={innerTabContentHeight + EXTRA_HEIGHT_RESULTS}
database={databases[latestQuery.dbId]}
displayLimit={displayLimit}
defaultQueryLimit={defaultQueryLimit}
showSql
showSqlInline
/>
);
}
} else {
results = (
<StyledEmptyStateWrapper>
<EmptyStateMedium
title={t('Run a query to display results')}
image="document.svg"
/>
</StyledEmptyStateWrapper>
);
}
return results;
};
const renderDataPreviewTabs = () =>
dataPreviewQueries.map(query => (
<Tabs.TabPane
tab={t('Preview: `%s`', decodeURIComponent(query.tableName))}
key={query.id}
>
<ResultSet
query={query}
visualize={false}
csv={false}
cache
user={user}
height={innerTabContentHeight}
displayLimit={displayLimit}
defaultQueryLimit={defaultQueryLimit}
/>
</Tabs.TabPane>
));
return offline ? (
renderOfflineStatus()
) : (
<StyledPane
data-test="south-pane"
@ -243,16 +144,41 @@ const SouthPane = ({
animated={false}
>
<Tabs.TabPane tab={t('Results')} key="Results">
{renderResults()}
{latestQueryId && (
<Results
height={innerTabContentHeight}
latestQueryId={latestQueryId}
displayLimit={displayLimit}
defaultQueryLimit={defaultQueryLimit}
/>
)}
</Tabs.TabPane>
<Tabs.TabPane tab={t('Query history')} key="History">
<QueryHistory
queries={editorQueries}
queryEditorId={queryEditorId}
displayLimit={displayLimit}
latestQueryId={latestQueryId}
/>
</Tabs.TabPane>
{renderDataPreviewTabs()}
{dataPreviewQueries.map(
({ name, dataPreviewQueryId }) =>
dataPreviewQueryId && (
<Tabs.TabPane
tab={t('Preview: `%s`', decodeURIComponent(name))}
key={dataPreviewQueryId}
>
<ResultSet
queryId={dataPreviewQueryId}
visualize={false}
csv={false}
cache
height={innerTabContentHeight}
displayLimit={displayLimit}
defaultQueryLimit={defaultQueryLimit}
/>
</Tabs.TabPane>
),
)}
</Tabs>
</StyledPane>
);

View File

@ -145,8 +145,8 @@ describe('SqlEditor', () => {
(SqlEditorLeftBar as jest.Mock).mockImplementation(() => (
<div data-test="mock-sql-editor-left-bar" />
));
(ResultSet as jest.Mock).mockClear();
(ResultSet as jest.Mock).mockImplementation(() => (
(ResultSet as unknown as jest.Mock).mockClear();
(ResultSet as unknown as jest.Mock).mockImplementation(() => (
<div data-test="mock-result-set" />
));
});
@ -182,7 +182,8 @@ describe('SqlEditor', () => {
const editor = await findByTestId('react-ace');
const sql = 'select *';
const renderCount = (SqlEditorLeftBar as jest.Mock).mock.calls.length;
const renderCountForSouthPane = (ResultSet as jest.Mock).mock.calls.length;
const renderCountForSouthPane = (ResultSet as unknown as jest.Mock).mock
.calls.length;
expect(SqlEditorLeftBar).toHaveBeenCalledTimes(renderCount);
expect(ResultSet).toHaveBeenCalledTimes(renderCountForSouthPane);
fireEvent.change(editor, { target: { value: sql } });

View File

@ -345,7 +345,7 @@ export default function sqlLabReducer(state = {}, action) {
return state;
}
const alts = {
endDttm: now(),
endDttm: action?.results?.query?.endDttm || now(),
progress: 100,
results: action.results,
rows: action?.results?.query?.rows || 0,
@ -674,7 +674,14 @@ export default function sqlLabReducer(state = {}, action) {
if (!change) {
newQueries = state.queries;
}
return { ...state, queries: newQueries, queriesLastUpdate };
return {
...state,
queries: newQueries,
queriesLastUpdate:
queriesLastUpdate > state.queriesLastUpdate
? queriesLastUpdate
: Date.now(),
};
},
[actions.CLEAR_INACTIVE_QUERIES]() {
const { queries } = state;
@ -701,7 +708,11 @@ export default function sqlLabReducer(state = {}, action) {
},
]),
);
return { ...state, queries: cleanedQueries };
return {
...state,
queries: cleanedQueries,
queriesLastUpdate: Date.now(),
};
},
[actions.SET_USER_OFFLINE]() {
return { ...state, offline: action.offline };

View File

@ -20,6 +20,7 @@ import { QueryState } from '@superset-ui/core';
import sqlLabReducer from 'src/SqlLab/reducers/sqlLab';
import * as actions from 'src/SqlLab/actions/sqlLab';
import { table, initialState as mockState } from '../fixtures';
import { QUERY_UPDATE_FREQ } from '../components/QueryAutoRefresh';
const initialState = mockState.sqlLab;
@ -404,6 +405,7 @@ describe('sqlLabReducer', () => {
};
});
it('updates queries that have already been completed', () => {
const current = Date.now();
newState = sqlLabReducer(
{
...newState,
@ -418,9 +420,10 @@ describe('sqlLabReducer', () => {
},
},
},
actions.clearInactiveQueries(Date.now()),
actions.clearInactiveQueries(QUERY_UPDATE_FREQ),
);
expect(newState.queries.abcd.state).toBe(QueryState.SUCCESS);
expect(newState.queriesLastUpdate).toBeGreaterThanOrEqual(current);
});
});
});