This is an automated email from the ASF dual-hosted git repository.
michaelsmolina pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new c590e90c87 feat(sqllab): improve table metadata UI (#32051)
c590e90c87 is described below
commit c590e90c875156fc4298423c91ca5571dbcebeca
Author: JUST.in DO IT <[email protected]>
AuthorDate: Fri Jan 31 10:19:37 2025 -0800
feat(sqllab): improve table metadata UI (#32051)
---
.../superset-ui-core/src/ui-overrides/types.ts | 11 +
superset-frontend/src/SqlLab/actions/sqlLab.js | 40 +-
.../src/SqlLab/actions/sqlLab.test.js | 70 +++-
.../AceEditorWrapper/AceEditorWrapper.test.tsx | 19 +-
.../AceEditorWrapper/useKeywords.test.ts | 2 +
.../src/SqlLab/components/App/index.tsx | 2 +-
.../src/SqlLab/components/ShowSQL/index.tsx | 12 +-
.../SqlLab/components/SouthPane/SouthPane.test.tsx | 6 +-
.../src/SqlLab/components/SouthPane/index.tsx | 95 +++--
.../SqlEditorLeftBar/SqlEditorLeftBar.test.tsx | 63 ++-
.../SqlLab/components/SqlEditorLeftBar/index.tsx | 11 +-
.../components/TableElement/TableElement.test.tsx | 10 +-
.../components/TablePreview/TablePreview.test.tsx | 173 +++++++++
.../src/SqlLab/components/TablePreview/index.tsx | 430 +++++++++++++++++++++
superset-frontend/src/SqlLab/fixtures.ts | 5 +-
.../src/SqlLab/reducers/getInitialState.test.ts | 10 +-
.../src/SqlLab/reducers/getInitialState.ts | 7 +-
superset-frontend/src/SqlLab/reducers/sqlLab.js | 50 ++-
superset-frontend/src/SqlLab/types.ts | 2 +-
.../src/components/Icons/AntdEnhanced.tsx | 2 +
superset-frontend/src/hooks/apiResources/sqlLab.ts | 4 +-
superset-frontend/src/hooks/apiResources/tables.ts | 10 +-
22 files changed, 889 insertions(+), 145 deletions(-)
diff --git
a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
index 3f0ff82b2f..6583bee732 100644
--- a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
+++ b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
@@ -144,6 +144,13 @@ export interface SQLResultTableExtensionProps {
allowHTML?: boolean;
}
+export interface SQLTablePreviewExtensionProps {
+ dbId: number;
+ catalog?: string;
+ schema: string;
+ tableName: string;
+}
+
/**
* Interface for extensions to Slice Header
*/
@@ -229,4 +236,8 @@ export type Extensions = Partial<{
'sqleditor.extension.customAutocomplete': (
args: CustomAutoCompleteArgs,
) => CustomAutocomplete[] | undefined;
+ 'sqleditor.extension.tablePreview': [
+ string,
+ ComponentType<SQLTablePreviewExtensionProps>,
+ ][];
}>;
diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js
b/superset-frontend/src/SqlLab/actions/sqlLab.js
index d8ec7a19a5..9f62d781ed 100644
--- a/superset-frontend/src/SqlLab/actions/sqlLab.js
+++ b/superset-frontend/src/SqlLab/actions/sqlLab.js
@@ -237,7 +237,7 @@ export function clearInactiveQueries(interval) {
return { type: CLEAR_INACTIVE_QUERIES, interval };
}
-export function startQuery(query) {
+export function startQuery(query, runPreviewOnly) {
Object.assign(query, {
id: query.id ? query.id : nanoid(11),
progress: 0,
@@ -245,7 +245,7 @@ export function startQuery(query) {
state: query.runAsync ? 'pending' : 'running',
cached: false,
});
- return { type: START_QUERY, query };
+ return { type: START_QUERY, query, runPreviewOnly };
}
export function querySuccess(query, results) {
@@ -327,9 +327,9 @@ export function fetchQueryResults(query, displayLimit,
timeoutInMs) {
};
}
-export function runQuery(query) {
+export function runQuery(query, runPreviewOnly) {
return function (dispatch) {
- dispatch(startQuery(query));
+ dispatch(startQuery(query, runPreviewOnly));
const postPayload = {
client_id: query.id,
database_id: query.dbId,
@@ -947,29 +947,25 @@ export function mergeTable(table, query, prepend) {
export function addTable(queryEditor, tableName, catalogName, schemaName) {
return function (dispatch, getState) {
- const query = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
+ const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
const table = {
- dbId: query.dbId,
- queryEditorId: query.id,
+ dbId,
+ queryEditorId: queryEditor.id,
catalog: catalogName,
schema: schemaName,
name: tableName,
};
dispatch(
- mergeTable(
- {
- ...table,
- id: nanoid(11),
- expanded: true,
- },
- null,
- true,
- ),
+ mergeTable({
+ ...table,
+ id: nanoid(11),
+ expanded: true,
+ }),
);
};
}
-export function runTablePreviewQuery(newTable) {
+export function runTablePreviewQuery(newTable, runPreviewOnly) {
return function (dispatch, getState) {
const {
sqlLab: { databases },
@@ -979,7 +975,7 @@ export function runTablePreviewQuery(newTable) {
if (database && !database.disable_data_preview) {
const dataPreviewQuery = {
- id: nanoid(11),
+ id: newTable.previewQueryId ?? nanoid(11),
dbId,
catalog,
schema,
@@ -991,6 +987,9 @@ export function runTablePreviewQuery(newTable) {
ctas: false,
isDataPreview: true,
};
+ if (runPreviewOnly) {
+ return dispatch(runQuery(dataPreviewQuery, runPreviewOnly));
+ }
return Promise.all([
dispatch(
mergeTable(
@@ -1024,7 +1023,7 @@ export function syncTable(table, tableMetadata) {
return sync
.then(({ json: resultJson }) => {
- const newTable = { ...table, id: resultJson.id };
+ const newTable = { ...table, id: `${resultJson.id}` };
dispatch(
mergeTable({
...newTable,
@@ -1032,9 +1031,6 @@ export function syncTable(table, tableMetadata) {
initialized: true,
}),
);
- if (!table.dataPreviewQueryId) {
- dispatch(runTablePreviewQuery({ ...tableMetadata, ...newTable }));
- }
})
.catch(() =>
dispatch(
diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.test.js
b/superset-frontend/src/SqlLab/actions/sqlLab.test.js
index 58ba6b764d..abbdb0c99e 100644
--- a/superset-frontend/src/SqlLab/actions/sqlLab.test.js
+++ b/superset-frontend/src/SqlLab/actions/sqlLab.test.js
@@ -1031,30 +1031,38 @@ describe('async actions', () => {
});
describe('runTablePreviewQuery', () => {
- it('updates and runs data preview query when configured', () => {
- expect.assertions(3);
+ const results = {
+ data: mockBigNumber,
+ query: { sqlEditorId: 'null', dbId: 1 },
+ query_id: 'efgh',
+ };
+ const tableName = 'table';
+ const catalogName = null;
+ const schemaName = 'schema';
+ const store = mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ databases: {
+ 1: { disable_data_preview: false },
+ },
+ },
+ });
- const results = {
- data: mockBigNumber,
- query: { sqlEditorId: 'null', dbId: 1 },
- query_id: 'efgh',
- };
+ beforeEach(() => {
fetchMock.post(runQueryEndpoint, JSON.stringify(results), {
overwriteRoutes: true,
});
+ });
+
+ afterEach(() => {
+ store.clearActions();
+ fetchMock.resetHistory();
+ });
+
+ it('updates and runs data preview query when configured', () => {
+ expect.assertions(3);
- const tableName = 'table';
- const catalogName = null;
- const schemaName = 'schema';
- const store = mockStore({
- ...initialState,
- sqlLab: {
- ...initialState.sqlLab,
- databases: {
- 1: { disable_data_preview: false },
- },
- },
- });
const expectedActionTypes = [
actions.MERGE_TABLE, // addTable (data preview)
actions.START_QUERY, // runQuery (data preview)
@@ -1075,6 +1083,30 @@ describe('async actions', () => {
expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0);
});
});
+
+ it('runs data preview query only', () => {
+ const expectedActionTypes = [
+ actions.START_QUERY, // runQuery (data preview)
+ actions.QUERY_SUCCESS, // querySuccess
+ ];
+ const request = actions.runTablePreviewQuery(
+ {
+ dbId: 1,
+ name: tableName,
+ catalog: catalogName,
+ schema: schemaName,
+ },
+ true,
+ );
+ return request(store.dispatch, store.getState).then(() => {
+ expect(store.getActions().map(a => a.type)).toEqual(
+ expectedActionTypes,
+ );
+ expect(fetchMock.calls(runQueryEndpoint)).toHaveLength(1);
+ // tab state is not updated, since the query is a data preview
+ expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0);
+ });
+ });
});
describe('expandTable', () => {
diff --git
a/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx
b/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx
index e2abec9f8c..82a79103fe 100644
---
a/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx
+++
b/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx
@@ -16,8 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
-import configureStore from 'redux-mock-store';
-import thunk from 'redux-thunk';
import reducerIndex from 'spec/helpers/reducerIndex';
import { render, waitFor, createStore } from 'spec/helpers/testing-library';
import { QueryEditor } from 'src/SqlLab/types';
@@ -34,9 +32,6 @@ import {
} from 'src/SqlLab/actions/sqlLab';
import fetchMock from 'fetch-mock';
-const middlewares = [thunk];
-const mockStore = configureStore(middlewares);
-
fetchMock.get('glob:*/api/v1/database/*/function_names/', {
function_names: [],
});
@@ -79,7 +74,8 @@ describe('AceEditorWrapper', () => {
});
it('renders ace editor including sql value', async () => {
- const { getByTestId } = setup(defaultQueryEditor, mockStore(initialState));
+ const store = createStore(initialState, reducerIndex);
+ const { getByTestId } = setup(defaultQueryEditor, store);
await waitFor(() => expect(getByTestId('react-ace')).toBeInTheDocument());
expect(getByTestId('react-ace')).toHaveTextContent(
@@ -89,9 +85,8 @@ describe('AceEditorWrapper', () => {
it('renders current sql for unrelated unsaved changes', () => {
const expectedSql = 'SELECT updated_column\nFROM updated_table\nWHERE';
- const { getByTestId } = setup(
- defaultQueryEditor,
- mockStore({
+ const store = createStore(
+ {
...initialState,
sqlLab: {
...initialState.sqlLab,
@@ -100,8 +95,10 @@ describe('AceEditorWrapper', () => {
sql: expectedSql,
},
},
- }),
+ },
+ reducerIndex,
);
+ const { getByTestId } = setup(defaultQueryEditor, store);
expect(getByTestId('react-ace')).not.toHaveTextContent(
JSON.stringify({ value: expectedSql }).slice(1, -1),
@@ -122,7 +119,7 @@ describe('AceEditorWrapper', () => {
queryEditorSetCursorPosition(defaultQueryEditor, updatedCursorPosition),
);
expect(FullSQLEditor).toHaveBeenCalledTimes(renderCount);
- store.dispatch(queryEditorSetDb(defaultQueryEditor, 1));
+ store.dispatch(queryEditorSetDb(defaultQueryEditor, 2));
expect(FullSQLEditor).toHaveBeenCalledTimes(renderCount + 1);
});
});
diff --git
a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts
b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts
index 350e7ec121..e0f49b70e3 100644
---
a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts
+++
b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts
@@ -202,6 +202,7 @@ test('returns column keywords among selected tables', async
() => {
{
name: expectColumn,
type: 'VARCHAR',
+ longType: 'VARCHAR',
},
],
},
@@ -223,6 +224,7 @@ test('returns column keywords among selected tables', async
() => {
{
name: unexpectedColumn,
type: 'VARCHAR',
+ longType: 'VARCHAR',
},
],
},
diff --git a/superset-frontend/src/SqlLab/components/App/index.tsx
b/superset-frontend/src/SqlLab/components/App/index.tsx
index 7da80d8e9c..8b9c279083 100644
--- a/superset-frontend/src/SqlLab/components/App/index.tsx
+++ b/superset-frontend/src/SqlLab/components/App/index.tsx
@@ -47,7 +47,7 @@ const SqlLabStyles = styled.div`
left: 0;
padding: 0 ${theme.gridUnit * 2}px;
- pre {
+ pre:not(.code) {
padding: 0 !important;
margin: 0;
border: none;
diff --git a/superset-frontend/src/SqlLab/components/ShowSQL/index.tsx
b/superset-frontend/src/SqlLab/components/ShowSQL/index.tsx
index 3e0192d051..4ae6f3b0ed 100644
--- a/superset-frontend/src/SqlLab/components/ShowSQL/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ShowSQL/index.tsx
@@ -28,21 +28,25 @@ interface ShowSQLProps {
sql: string;
title: string;
tooltipText: string;
+ triggerNode?: React.ReactNode;
}
export default function ShowSQL({
tooltipText,
title,
sql: sqlString,
+ triggerNode,
}: ShowSQLProps) {
return (
<ModalTrigger
modalTitle={title}
triggerNode={
- <IconTooltip
- className="fa fa-eye pull-left m-l-2"
- tooltip={tooltipText}
- />
+ triggerNode || (
+ <IconTooltip
+ className="fa fa-eye pull-left m-l-2"
+ tooltip={tooltipText}
+ />
+ )
}
modalBody={
<div>
diff --git
a/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.tsx
b/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.tsx
index 3fc6df96fb..0d30c46f1e 100644
--- a/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.tsx
+++ b/superset-frontend/src/SqlLab/components/SouthPane/SouthPane.test.tsx
@@ -135,7 +135,7 @@ test('should render empty result state when latestQuery is
empty', () => {
expect(resultPanel).toHaveTextContent('Run a query to display results');
});
-test('should render tabs for table preview queries', () => {
+test('should render tabs for table metadata view', () => {
const { getAllByRole } = render(<SouthPane {...mockedProps} />, {
useRedux: true,
initialState: mockState,
@@ -145,7 +145,7 @@ test('should render tabs for table preview queries', () => {
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}\``);
+ mockState.sqlLab.tables.forEach(({ name, schema }, index) => {
+ expect(tabs[index + 2]).toHaveTextContent(`${schema}.${name}`);
});
});
diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
index 7af63b3958..dbde83ae9d 100644
--- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
@@ -16,24 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { createRef, useMemo } from 'react';
+import { createRef, useCallback, useMemo } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { nanoid } from 'nanoid';
import Tabs from 'src/components/Tabs';
-import { styled, t } from '@superset-ui/core';
+import { css, styled, t } from '@superset-ui/core';
-import { setActiveSouthPaneTab } from 'src/SqlLab/actions/sqlLab';
+import { removeTables, setActiveSouthPaneTab } from
'src/SqlLab/actions/sqlLab';
import Label from 'src/components/Label';
+import Icons from 'src/components/Icons';
import { SqlLabRootState } from 'src/SqlLab/types';
import QueryHistory from '../QueryHistory';
-import ResultSet from '../ResultSet';
import {
STATUS_OPTIONS,
STATE_TYPE_MAP,
STATUS_OPTIONS_LOCALIZED,
} from '../../constants';
import Results from './Results';
+import TablePreview from '../TablePreview';
const TAB_HEIGHT = 130;
@@ -98,31 +99,45 @@ const SouthPane = ({
}),
shallowEqual,
);
- const queries = useSelector(
- ({ sqlLab: { queries } }: SqlLabRootState) => Object.keys(queries),
- shallowEqual,
- );
const activeSouthPaneTab =
useSelector<SqlLabRootState, string>(
state => state.sqlLab.activeSouthPaneTab as string,
) ?? 'Results';
- const querySet = useMemo(() => new Set(queries), [queries]);
- const dataPreviewQueries = useMemo(
+ const pinnedTables = useMemo(
() =>
tables.filter(
- ({ dataPreviewQueryId, queryEditorId: qeId }) =>
- dataPreviewQueryId &&
- queryEditorId === qeId &&
- querySet.has(dataPreviewQueryId),
+ ({ queryEditorId: qeId }) => String(queryEditorId) === qeId,
+ ),
+ [queryEditorId, tables],
+ );
+ const pinnedTableKeys = useMemo(
+ () =>
+ Object.fromEntries(
+ pinnedTables.map(({ id, dbId, catalog, schema, name }) => [
+ id,
+ [dbId, catalog, schema, name].join(':'),
+ ]),
),
- [queryEditorId, tables, querySet],
+ [pinnedTables],
);
const innerTabContentHeight = height - TAB_HEIGHT;
const southPaneRef = createRef<HTMLDivElement>();
const switchTab = (id: string) => {
dispatch(setActiveSouthPaneTab(id));
};
+ const removeTable = useCallback(
+ (key, action) => {
+ if (action === 'remove') {
+ const table = pinnedTables.find(
+ ({ dbId, catalog, schema, name }) =>
+ [dbId, catalog, schema, name].join(':') === key,
+ );
+ dispatch(removeTables([table]));
+ }
+ },
+ [dispatch, queryEditorId],
+ );
return offline ? (
<Label className="m-r-3" type={STATE_TYPE_MAP[STATUS_OPTIONS.offline]}>
@@ -136,14 +151,17 @@ const SouthPane = ({
ref={southPaneRef}
>
<Tabs
- activeKey={activeSouthPaneTab}
+ type="editable-card"
+ activeKey={pinnedTableKeys[activeSouthPaneTab] || activeSouthPaneTab}
className="SouthPaneTabs"
onChange={switchTab}
id={nanoid(11)}
fullWidth={false}
animated={false}
+ onEdit={removeTable}
+ hideAdd
>
- <Tabs.TabPane tab={t('Results')} key="Results">
+ <Tabs.TabPane tab={t('Results')} key="Results" closable={false}>
<Results
height={innerTabContentHeight}
latestQueryId={latestQueryId}
@@ -151,32 +169,37 @@ const SouthPane = ({
defaultQueryLimit={defaultQueryLimit}
/>
</Tabs.TabPane>
- <Tabs.TabPane tab={t('Query history')} key="History">
+ <Tabs.TabPane tab={t('Query history')} key="History" closable={false}>
<QueryHistory
queryEditorId={queryEditorId}
displayLimit={displayLimit}
latestQueryId={latestQueryId}
/>
</Tabs.TabPane>
- {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}
+ {pinnedTables.map(({ id, dbId, catalog, schema, name }) => (
+ <Tabs.TabPane
+ tab={
+ <>
+ <Icons.Table
+ iconSize="s"
+ css={css`
+ margin-bottom: 2px;
+ margin-right: 4px;
+ `}
/>
- </Tabs.TabPane>
- ),
- )}
+ {`${schema}.${decodeURIComponent(name)}`}
+ </>
+ }
+ key={pinnedTableKeys[id]}
+ >
+ <TablePreview
+ dbId={dbId}
+ catalog={catalog}
+ schema={schema}
+ tableName={name}
+ />
+ </Tabs.TabPane>
+ ))}
</Tabs>
</StyledPane>
);
diff --git
a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx
b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx
index 3abb5b400e..1604283be6 100644
---
a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx
+++
b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx
@@ -27,6 +27,7 @@ import {
initialState,
defaultQueryEditor,
extraQueryEditor1,
+ extraQueryEditor2,
} from 'src/SqlLab/fixtures';
import type { RootState } from 'src/views/store';
import type { Store } from 'redux';
@@ -206,13 +207,13 @@ test('should toggle the table when the header is
clicked', async () => {
});
test('When changing database the schema and table list must be updated', async
() => {
- const { rerender } = await renderAndWait(mockedProps, undefined, {
+ const reduxState = {
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
- schema: 'new_schema',
+ schema: 'db1_schema',
},
queryEditors: [
defaultQueryEditor,
@@ -223,16 +224,22 @@ test('When changing database the schema and table list
must be updated', async (
},
],
tables: [
- table,
+ {
+ ...table,
+ dbId: defaultQueryEditor.dbId,
+ schema: 'db1_schema',
+ },
{
...table,
dbId: 2,
+ schema: 'new_schema',
name: 'new_table',
queryEditorId: extraQueryEditor1.id,
},
],
},
- });
+ };
+ const { rerender } = await renderAndWait(mockedProps, undefined, reduxState);
expect(screen.getAllByText(/main/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/ab_user/i)[0]).toBeInTheDocument();
@@ -250,30 +257,60 @@ test('When changing database the schema and table list
must be updated', async (
);
const updatedDbSelector = await screen.findAllByText(/new_db/i);
expect(updatedDbSelector[0]).toBeInTheDocument();
- const updatedTableSelector = await screen.findAllByText(/new_table/i);
- expect(updatedTableSelector[0]).toBeInTheDocument();
const select = screen.getByRole('combobox', {
name: 'Select schema or type to search schemas',
});
userEvent.click(select);
+
expect(
await screen.findByRole('option', { name: 'main' }),
).toBeInTheDocument();
expect(
await screen.findByRole('option', { name: 'new_schema' }),
).toBeInTheDocument();
- rerender(
- <SqlEditorLeftBar
- {...mockedProps}
- database={{
+
+ userEvent.click(screen.getAllByText('new_schema')[1]);
+
+ const updatedTableSelector = await screen.findAllByText(/new_table/i);
+ expect(updatedTableSelector[0]).toBeInTheDocument();
+});
+
+test('display no compatible schema found when schema api throws errors', async
() => {
+ const reduxState = {
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ queryEditors: [
+ {
+ ...extraQueryEditor2,
+ dbId: 3,
+ schema: undefined,
+ },
+ ],
+ },
+ };
+ await renderAndWait(
+ {
+ ...mockedProps,
+ queryEditorId: extraQueryEditor2.id,
+ database: {
id: 3,
database_name: 'unauth_db',
backend: 'minervasql',
- }}
- queryEditorId={extraQueryEditor1.id}
- />,
+ },
+ },
+ undefined,
+ reduxState,
);
+ await waitFor(() =>
+
expect(fetchMock.calls('glob:*/api/v1/database/3/schemas/?*')).toHaveLength(
+ 1,
+ ),
+ );
+ const select = screen.getByRole('combobox', {
+ name: 'Select schema or type to search schemas',
+ });
userEvent.click(select);
expect(
await screen.findByText('No compatible schema found'),
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
index 1e0012aed2..360376a1d8 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
@@ -101,7 +101,7 @@ const SqlEditorLeftBar = ({
queryEditorId,
height = 500,
}: SqlEditorLeftBarProps) => {
- const tables = useSelector<SqlLabRootState, Table[]>(
+ const allSelectedTables = useSelector<SqlLabRootState, Table[]>(
({ sqlLab }) =>
sqlLab.tables.filter(table => table.queryEditorId === queryEditorId),
shallowEqual,
@@ -117,7 +117,14 @@ const SqlEditorLeftBar = ({
const [userSelectedDb, setUserSelected] = useState<DatabaseObject | null>(
null,
);
- const { catalog, schema } = queryEditor;
+ const { dbId, catalog, schema } = queryEditor;
+ const tables = useMemo(
+ () =>
+ allSelectedTables.filter(
+ table => table.dbId === dbId && table.schema === schema,
+ ),
+ [allSelectedTables, dbId, schema],
+ );
useEffect(() => {
const bool = querystring.parse(window.location.search).db;
diff --git
a/superset-frontend/src/SqlLab/components/TableElement/TableElement.test.tsx
b/superset-frontend/src/SqlLab/components/TableElement/TableElement.test.tsx
index b3412ba31b..b9cd94e566 100644
--- a/superset-frontend/src/SqlLab/components/TableElement/TableElement.test.tsx
+++ b/superset-frontend/src/SqlLab/components/TableElement/TableElement.test.tsx
@@ -92,7 +92,7 @@ test('has 4 IconTooltip elements', async () => {
initialState,
});
await waitFor(() =>
- expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(5),
+ expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
);
});
@@ -112,7 +112,7 @@ test('fades table', async () => {
initialState,
});
await waitFor(() =>
- expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(5),
+ expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
);
const style = window.getComputedStyle(getAllByTestId('fade')[0]);
expect(style.opacity).toBe('0');
@@ -133,7 +133,7 @@ test('sorts columns', async () => {
},
);
await waitFor(() =>
- expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(5),
+ expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
);
expect(
getAllByTestId('mock-column-element').map(el => el.textContent),
@@ -160,7 +160,7 @@ test('removes the table', async () => {
},
);
await waitFor(() =>
- expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(5),
+ expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
);
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(0);
fireEvent.click(getByText('Remove table preview'));
@@ -193,7 +193,7 @@ test('refreshes table metadata when triggered', async () =>
{
},
);
await waitFor(() =>
- expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(5),
+ expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
);
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(0);
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1);
diff --git
a/superset-frontend/src/SqlLab/components/TablePreview/TablePreview.test.tsx
b/superset-frontend/src/SqlLab/components/TablePreview/TablePreview.test.tsx
new file mode 100644
index 0000000000..b4962c89e6
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/TablePreview/TablePreview.test.tsx
@@ -0,0 +1,173 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { type ReactChild } from 'react';
+import fetchMock from 'fetch-mock';
+import { table, initialState } from 'src/SqlLab/fixtures';
+import {
+ render,
+ waitFor,
+ fireEvent,
+ screen,
+} from 'spec/helpers/testing-library';
+import TablePreview from '.';
+
+jest.mock(
+ 'src/components/FilterableTable',
+ () =>
+ ({ data }: { data: Record<string, any>[] }) => (
+ <div>
+ {data.map((record, i) => (
+ <div key={i} data-test="mock-record-row">
+ {JSON.stringify(record)}
+ </div>
+ ))}
+ </div>
+ ),
+);
+jest.mock(
+ 'react-virtualized-auto-sizer',
+ () =>
+ ({ children }: { children: (params: { height: number }) => ReactChild }) =>
+ children({ height: 500 }),
+);
+jest.mock('src/components/IconTooltip', () => ({
+ IconTooltip: ({
+ onClick,
+ tooltip,
+ }: {
+ onClick: () => void;
+ tooltip: string;
+ }) => (
+ <button type="button" data-test="mock-icon-tooltip" onClick={onClick}>
+ {tooltip}
+ </button>
+ ),
+}));
+const getTableMetadataEndpoint =
+ /\/api\/v1\/database\/\d+\/table_metadata\/(?:\?.*)?$/;
+const getExtraTableMetadataEndpoint =
+ /\/api\/v1\/database\/\d+\/table_metadata\/extra\/(?:\?.*)?$/;
+const fetchPreviewEndpoint = 'glob:*/api/v1/sqllab/execute/';
+
+beforeEach(() => {
+ fetchMock.get(getTableMetadataEndpoint, table);
+ fetchMock.get(getExtraTableMetadataEndpoint, {});
+ fetchMock.post(fetchPreviewEndpoint, `{ "data": 123 }`);
+});
+
+afterEach(() => {
+ fetchMock.reset();
+});
+
+const mockedProps = {
+ dbId: table.dbId,
+ catalog: table.catalog,
+ schema: table.schema,
+ tableName: table.name,
+};
+
+test('renders columns', async () => {
+ const { getAllByTestId, queryByText } = render(
+ <TablePreview {...mockedProps} />,
+ {
+ useRedux: true,
+ initialState,
+ },
+ );
+ await waitFor(() =>
+ expect(getAllByTestId('mock-record-row')).toHaveLength(
+ table.columns.length,
+ ),
+ );
+ expect(queryByText(`Columns (${table.columns.length})`)).toBeInTheDocument();
+});
+
+test('renders indexes', async () => {
+ const { queryByText } = render(<TablePreview {...mockedProps} />, {
+ useRedux: true,
+ initialState,
+ });
+ await waitFor(() =>
+ expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
+ );
+ expect(queryByText(`Indexes (${table.indexes.length})`)).toBeInTheDocument();
+});
+
+test('renders preview', async () => {
+ const { getByText } = render(<TablePreview {...mockedProps} />, {
+ useRedux: true,
+ initialState: {
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ databases: {
+ [table.dbId]: {
+ id: table.dbId,
+ database_name: 'mysql',
+ disable_data_preview: false,
+ },
+ },
+ },
+ },
+ });
+ await waitFor(() =>
+ expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
+ );
+ expect(fetchMock.calls(fetchPreviewEndpoint)).toHaveLength(0);
+ fireEvent.click(getByText('Data preview'));
+ await waitFor(() =>
+ expect(fetchMock.calls(fetchPreviewEndpoint)).toHaveLength(1),
+ );
+});
+
+describe('table actions', () => {
+ test('refreshes table metadata when triggered', async () => {
+ const { getByRole, getByText } = render(<TablePreview {...mockedProps} />,
{
+ useRedux: true,
+ initialState,
+ });
+ await waitFor(() =>
+ expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
+ );
+ const menuButton = getByRole('button', { name: /Table actions/i });
+ fireEvent.click(menuButton);
+ fireEvent.click(getByText('Refresh table schema'));
+ await waitFor(() =>
+ expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(2),
+ );
+ });
+
+ test('shows CREATE VIEW statement', async () => {
+ const { getByRole, getByText } = render(<TablePreview {...mockedProps} />,
{
+ useRedux: true,
+ initialState,
+ });
+ await waitFor(() =>
+ expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
+ );
+ const menuButton = getByRole('button', { name: /Table actions/i });
+ fireEvent.click(menuButton);
+ fireEvent.click(getByText('Show CREATE VIEW statement'));
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('dialog', { name: 'CREATE VIEW statement' }),
+ ).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/superset-frontend/src/SqlLab/components/TablePreview/index.tsx
b/superset-frontend/src/SqlLab/components/TablePreview/index.tsx
new file mode 100644
index 0000000000..7f17b8e327
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/TablePreview/index.tsx
@@ -0,0 +1,430 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { type FC, useCallback, useMemo, useRef, useState } from 'react';
+import { shallowEqual, useDispatch, useSelector } from 'react-redux';
+import { nanoid } from 'nanoid';
+import {
+ ClientErrorObject,
+ css,
+ getExtensionsRegistry,
+ SafeMarkdown,
+ styled,
+ t,
+} from '@superset-ui/core';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import Icons from 'src/components/Icons';
+import type { SqlLabRootState } from 'src/SqlLab/types';
+import {
+ Skeleton,
+ AntdBreadcrumb as Breadcrumb,
+ AntdDropdown,
+} from 'src/components';
+import FilterableTable from 'src/components/FilterableTable';
+import Tabs from 'src/components/Tabs';
+import {
+ tableApiUtil,
+ TableMetaData,
+ useTableExtendedMetadataQuery,
+ useTableMetadataQuery,
+} from 'src/hooks/apiResources';
+import { runTablePreviewQuery } from 'src/SqlLab/actions/sqlLab';
+import Alert from 'src/components/Alert';
+import { Menu } from 'src/components/Menu';
+import Card from 'src/components/Card';
+import CopyToClipboard from 'src/components/CopyToClipboard';
+import ResultSet from '../ResultSet';
+import ShowSQL from '../ShowSQL';
+
+type Props = {
+ dbId: number | string;
+ schema?: string;
+ catalog?: string | null;
+ tableName: string;
+};
+
+const extensionsRegistry = getExtensionsRegistry();
+
+const COLUMN_KEYS = ['column_name', 'column_type', 'keys', 'comment'];
+const MENUS = [
+ {
+ key: 'refresh-table',
+ label: t('Refresh table schema'),
+ icon: <i aria-hidden className="fa fa-refresh" />,
+ },
+ {
+ key: 'copy-select-statement',
+ label: t('Copy SELECT statement'),
+ icon: <i aria-hidden className="fa fa-clipboard m-l-2" />,
+ },
+ {
+ key: 'show-create-view-statement',
+ label: t('Show CREATE VIEW statement'),
+ icon: <i aria-hidden className="fa fa-eye" />,
+ },
+];
+const TAB_HEADER_HEIGHT = 80;
+const PREVIEW_TOP_ACTION_HEIGHT = 30;
+const PREVIEW_QUERY_LIMIT = 100;
+
+const Title = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ column-gap: ${({ theme }) => theme.gridUnit}px;
+ font-size: ${({ theme }) => theme.typography.sizes.l}px;
+ font-weight: ${({ theme }) => theme.typography.weights.bold};
+`;
+
+const renderWell = (partitions: TableMetaData['partitions']) => {
+ if (!partitions) {
+ return null;
+ }
+ const { partitionQuery } = partitions;
+ let partitionClipBoard;
+ if (partitionQuery) {
+ const tt = t('Copy partition query to clipboard');
+ partitionClipBoard = (
+ <CopyToClipboard
+ text={partitionQuery}
+ shouldShowText={false}
+ tooltipText={tt}
+ copyNode={<i className="fa fa-clipboard" />}
+ />
+ );
+ }
+ const latest = Object.entries(partitions.latest || [])
+ .map(([key, value]) => `${key}=${value}`)
+ .join('/');
+
+ return (
+ <Card size="small">
+ <div>
+ <small>
+ {t('latest partition:')} {latest}
+ </small>{' '}
+ {partitionClipBoard}
+ </div>
+ </Card>
+ );
+};
+
+const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
+ const dispatch = useDispatch();
+ const [databaseName, backend, disableDataPreview] = useSelector<
+ SqlLabRootState,
+ string[]
+ >(
+ ({ sqlLab: { databases } }) => [
+ databases[dbId]?.database_name,
+ databases[dbId]?.backend,
+ databases[dbId]?.disable_data_preview,
+ ],
+ shallowEqual,
+ );
+ const copyStatementActionRef = useRef<HTMLButtonElement | null>(null);
+ const showViewStatementActionRef = useRef<HTMLButtonElement | null>(null);
+ const [previewQueryId, setPreviewQueryId] = useState<string>();
+ const {
+ currentData: tableMetadata,
+ isLoading: isMetadataLoading,
+ isFetching: isMetadataRefreshing,
+ isError: hasMetadataError,
+ error: metadataError,
+ } = useTableMetadataQuery(
+ {
+ dbId,
+ catalog,
+ schema: schema ?? '',
+ table: tableName ?? '',
+ },
+ { skip: !dbId || !schema || !tableName },
+ );
+ const { currentData: tableExtendedMetadata, error: metadataExtrError } =
+ useTableExtendedMetadataQuery(
+ {
+ dbId,
+ catalog,
+ schema: schema ?? '',
+ table: tableName ?? '',
+ },
+ { skip: !dbId || !schema || !tableName },
+ );
+ const data = useMemo(
+ () =>
+ (tableMetadata?.columns.length ?? 0) > 0
+ ? tableMetadata?.columns.map(
+ ({ name, type, longType, keys, comment }) => ({
+ column_name: name,
+ column_type: longType || type,
+ keys,
+ comment,
+ }),
+ )
+ : undefined,
+ [tableMetadata],
+ );
+ const hasKeys = useMemo(
+ () => data?.some(({ keys }) => Boolean(keys?.length)),
+ [data],
+ );
+ const columns = useMemo(
+ () => (hasKeys ? COLUMN_KEYS : COLUMN_KEYS.filter(name => name !==
'keys')),
+ [hasKeys],
+ );
+ const tableData = {
+ dataPreviewQueryId: previewQueryId,
+ ...tableMetadata,
+ ...tableExtendedMetadata,
+ };
+ const refreshTableMetadata = () => {
+ dispatch(
+ tableApiUtil.invalidateTags([{ type: 'TableMetadatas', id: tableName }]),
+ );
+ };
+ const ResultTable =
+ extensionsRegistry.get('sqleditor.extension.resultTable') ??
+ FilterableTable;
+ const customTabs =
+ extensionsRegistry.get('sqleditor.extension.tablePreview') ?? [];
+ const onTabSwitch = useCallback(
+ (activeKey: string) => {
+ if (activeKey === 'sample' && !previewQueryId) {
+ const queryId = nanoid(11);
+ dispatch(
+ runTablePreviewQuery(
+ {
+ previewQueryId: queryId,
+ dbId,
+ catalog,
+ schema,
+ name: tableName,
+ selectStar: tableData.selectStar,
+ },
+ true,
+ ),
+ );
+ setPreviewQueryId(queryId);
+ }
+ },
+ [
+ previewQueryId,
+ dbId,
+ catalog,
+ schema,
+ tableName,
+ tableData.selectStar,
+ dispatch,
+ ],
+ );
+
+ const dropdownMenu = useMemo(() => {
+ let menus = [...MENUS];
+ if (!tableData.selectStar) {
+ menus = menus.filter(({ key }) => key !== 'copy-select-statement');
+ }
+ if (!tableData.view) {
+ menus = menus.filter(({ key }) => key !== 'show-create-view-statement');
+ }
+ return menus;
+ }, [tableData.view, tableData.selectStar]);
+
+ if (isMetadataLoading) {
+ return <Skeleton active />;
+ }
+
+ if (hasMetadataError || metadataExtrError) {
+ return (
+ <Alert
+ type="warning"
+ message={
+ ((metadataError || metadataExtrError) as ClientErrorObject)?.error
+ }
+ />
+ );
+ }
+ if (!data) {
+ return (
+ <Alert
+ type="warning"
+ message={t('Cannot find the table (%s) metadata.', tableName)}
+ closable={false}
+ />
+ );
+ }
+ return (
+ <div
+ css={css`
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ `}
+ >
+ <Breadcrumb separator=">">
+ <Breadcrumb.Item>{backend}</Breadcrumb.Item>
+ <Breadcrumb.Item>{databaseName}</Breadcrumb.Item>
+ {catalog && <Breadcrumb.Item>{catalog}</Breadcrumb.Item>}
+ {schema && <Breadcrumb.Item>{schema}</Breadcrumb.Item>}
+ <Breadcrumb.Item> </Breadcrumb.Item>
+ </Breadcrumb>
+ <div style={{ display: 'none' }}>
+ <CopyToClipboard
+ copyNode={
+ <button type="button" ref={copyStatementActionRef}>
+ invisible button
+ </button>
+ }
+ text={tableData.selectStar}
+ shouldShowText={false}
+ />
+ {tableData.view && (
+ <ShowSQL
+ sql={tableData.view}
+ tooltipText={t('Show CREATE VIEW statement')}
+ title={t('CREATE VIEW statement')}
+ triggerNode={
+ <button type="button" ref={showViewStatementActionRef}>
+ invisible button
+ </button>
+ }
+ />
+ )}
+ </div>
+ <Title>
+ <Icons.Table iconSize="l" />
+ {tableName}
+ <AntdDropdown
+ overlay={
+ <Menu
+ onClick={({ key }) => {
+ if (key === 'refresh-table') {
+ refreshTableMetadata();
+ }
+ if (key === 'copy-select-statement') {
+ copyStatementActionRef.current?.click();
+ }
+ if (key === 'show-create-view-statement') {
+ showViewStatementActionRef.current?.click();
+ }
+ }}
+ items={dropdownMenu}
+ />
+ }
+ trigger={['click']}
+ >
+ <Icons.DownSquareOutlined
+ iconSize="m"
+ style={{ marginTop: 2, marginLeft: 4 }}
+ aria-label={t('Table actions')}
+ />
+ </AntdDropdown>
+ </Title>
+ {isMetadataRefreshing ? (
+ <Skeleton active />
+ ) : (
+ <>
+ {tableData.comment && <SafeMarkdown source={tableData.comment} />}
+ {renderWell(tableData.partitions)}
+ <div
+ css={css`
+ flex: 1 1 auto;
+ `}
+ >
+ <AutoSizer disableWidth>
+ {({ height }) => (
+ <Tabs
+ fullWidth={false}
+ onTabClick={onTabSwitch}
+ css={css`
+ height: ${height}px;
+ `}
+ >
+ <Tabs.TabPane
+ tab={t('Columns (%s)', data.length)}
+ key="columns"
+ >
+ <ResultTable
+ queryId="table-columns"
+ height={height - TAB_HEADER_HEIGHT}
+ data={data}
+ orderedColumnKeys={columns}
+ />
+ </Tabs.TabPane>
+ {tableData?.selectStar && !disableDataPreview && (
+ <Tabs.TabPane tab={t('Data preview')} key="sample">
+ {previewQueryId && (
+ <ResultSet
+ queryId={previewQueryId}
+ visualize={false}
+ csv={false}
+ cache
+ height={
+ height -
+ TAB_HEADER_HEIGHT -
+ PREVIEW_TOP_ACTION_HEIGHT
+ }
+ displayLimit={PREVIEW_QUERY_LIMIT}
+ defaultQueryLimit={PREVIEW_QUERY_LIMIT}
+ />
+ )}
+ </Tabs.TabPane>
+ )}
+ {tableData?.indexes && tableData.indexes.length > 0 && (
+ <Tabs.TabPane
+ tab={t('Indexes (%s)', tableData.indexes.length)}
+ key="indexes"
+ >
+ {tableData.indexes.map((ix, i) => (
+ <pre className="code" key={i}>
+ {JSON.stringify(ix, null, ' ')}
+ </pre>
+ ))}
+ </Tabs.TabPane>
+ )}
+ {tableData?.metadata && (
+ <Tabs.TabPane tab={t('Metadata')} key="metadata">
+ <ResultTable
+ queryId="table-metadata"
+ height={height - TAB_HEADER_HEIGHT}
+ data={Object.entries(tableData.metadata).map(
+ ([name, value]) => ({ name, value }),
+ )}
+ orderedColumnKeys={['name', 'value']}
+ />
+ </Tabs.TabPane>
+ )}
+ {customTabs.map(([title, ExtComponent]) => (
+ <Tabs.TabPane tab={title} key={title}>
+ <ExtComponent
+ dbId={Number(dbId)}
+ schema={schema ?? ''}
+ tableName={tableName}
+ />
+ </Tabs.TabPane>
+ ))}
+ </Tabs>
+ )}
+ </AutoSizer>
+ </div>
+ </>
+ )}
+ </div>
+ );
+};
+
+export default TablePreview;
diff --git a/superset-frontend/src/SqlLab/fixtures.ts
b/superset-frontend/src/SqlLab/fixtures.ts
index c974293d0e..075ccc2e53 100644
--- a/superset-frontend/src/SqlLab/fixtures.ts
+++ b/superset-frontend/src/SqlLab/fixtures.ts
@@ -38,9 +38,10 @@ export const table = {
selectStar: 'SELECT * FROM ab_user',
queryEditorId: 'dfsadfs',
catalog: null,
- schema: 'superset',
+ schema: 'main',
name: 'ab_user',
id: 'r11Vgt60',
+ view: 'SELECT * FROM ab_user',
dataPreviewQueryId: null,
partitions: {
cols: ['username'],
@@ -188,7 +189,7 @@ export const defaultQueryEditor = {
version: LatestQueryEditorVersion,
id: 'dfsadfs',
autorun: false,
- dbId: undefined,
+ dbId: 1,
latestQueryId: null,
selectedText: undefined,
sql: 'SELECT *\nFROM\nWHERE',
diff --git a/superset-frontend/src/SqlLab/reducers/getInitialState.test.ts
b/superset-frontend/src/SqlLab/reducers/getInitialState.test.ts
index f7e5897c41..01c05730c5 100644
--- a/superset-frontend/src/SqlLab/reducers/getInitialState.test.ts
+++ b/superset-frontend/src/SqlLab/reducers/getInitialState.test.ts
@@ -167,9 +167,10 @@ describe('getInitialState', () => {
table: 'table1',
tab_state_id: 1,
description: {
+ name: 'table1',
columns: [
- { name: 'id', type: 'INT' },
- { name: 'column2', type: 'STRING' },
+ { name: 'id', type: 'INT', longType: 'INT()' },
+ { name: 'column2', type: 'STRING', longType: 'STRING()' },
],
},
},
@@ -178,9 +179,10 @@ describe('getInitialState', () => {
table: 'table2',
tab_state_id: 1,
description: {
+ name: 'table2',
columns: [
- { name: 'id', type: 'INT' },
- { name: 'column2', type: 'STRING' },
+ { name: 'id', type: 'INT', longType: 'INT()' },
+ { name: 'column2', type: 'STRING', longType: 'STRING()' },
],
},
},
diff --git a/superset-frontend/src/SqlLab/reducers/getInitialState.ts
b/superset-frontend/src/SqlLab/reducers/getInitialState.ts
index 1f464d4ba6..361cc5621d 100644
--- a/superset-frontend/src/SqlLab/reducers/getInitialState.ts
+++ b/superset-frontend/src/SqlLab/reducers/getInitialState.ts
@@ -122,12 +122,12 @@ export default function getInitialState({
.forEach(tableSchema => {
const { dataPreviewQueryId, ...persistData } = tableSchema.description;
const table = {
- dbId: tableSchema.database_id,
+ dbId: tableSchema.database_id ?? 0,
queryEditorId: tableSchema.tab_state_id.toString(),
catalog: tableSchema.catalog,
schema: tableSchema.schema,
name: tableSchema.table,
- expanded: tableSchema.expanded,
+ expanded: Boolean(tableSchema.expanded),
id: tableSchema.id,
dataPreviewQueryId,
persistData,
@@ -147,7 +147,8 @@ export default function getInitialState({
}),
};
- const destroyedQueryEditors: Record<string, number> = {};
+ const destroyedQueryEditors:
SqlLabRootState['sqlLab']['destroyedQueryEditors'] =
+ {};
/**
* If the `SQLLAB_BACKEND_PERSISTENCE` feature flag is off, or if the user
diff --git a/superset-frontend/src/SqlLab/reducers/sqlLab.js
b/superset-frontend/src/SqlLab/reducers/sqlLab.js
index 5741ad8878..e0bc4307dc 100644
--- a/superset-frontend/src/SqlLab/reducers/sqlLab.js
+++ b/superset-frontend/src/SqlLab/reducers/sqlLab.js
@@ -187,30 +187,40 @@ export default function sqlLabReducer(state = {}, action)
{
},
[actions.MERGE_TABLE]() {
const at = { ...action.table };
- let existingTable;
- state.tables.forEach(xt => {
- if (
+ const existingTableIndex = state.tables.findIndex(
+ xt =>
xt.dbId === at.dbId &&
xt.queryEditorId === at.queryEditorId &&
xt.catalog === at.catalog &&
xt.schema === at.schema &&
- xt.name === at.name
- ) {
- existingTable = xt;
- }
- });
- if (existingTable) {
+ xt.name === at.name,
+ );
+ if (existingTableIndex >= 0) {
if (action.query) {
at.dataPreviewQueryId = action.query.id;
}
- if (existingTable.initialized) {
- at.id = existingTable.id;
- }
- return alterInArr(state, 'tables', existingTable, at);
+ return {
+ ...state,
+ tables: [
+ ...state.tables.slice(0, existingTableIndex),
+ {
+ ...state.tables[existingTableIndex],
+ ...at,
+ ...(state.tables[existingTableIndex].initialized && {
+ id: state.tables[existingTableIndex].id,
+ }),
+ },
+ ...state.tables.slice(existingTableIndex + 1),
+ ],
+ ...(at.expanded && {
+ activeSouthPaneTab: at.id,
+ }),
+ };
}
// for new table, associate Id of query for data preview
at.dataPreviewQueryId = null;
let newState = addToArr(state, 'tables', at, Boolean(action.prepend));
+ newState.activeSouthPaneTab = at.id;
if (action.query) {
newState = alterInArr(newState, 'tables', at, {
dataPreviewQueryId: action.query.id,
@@ -245,7 +255,6 @@ export default function sqlLabReducer(state = {}, action) {
...state,
queries,
tables: newTables,
- activeSouthPaneTab: action.newQuery.id,
};
},
[actions.COLLAPSE_TABLE]() {
@@ -253,9 +262,18 @@ export default function sqlLabReducer(state = {}, action) {
},
[actions.REMOVE_TABLES]() {
const tableIds = action.tables.map(table => table.id);
+ const tables = state.tables.filter(table =>
!tableIds.includes(table.id));
+
return {
...state,
- tables: state.tables.filter(table => !tableIds.includes(table.id)),
+ tables,
+ ...(tableIds.includes(state.activeSouthPaneTab) && {
+ activeSouthPaneTab:
+ tables.find(
+ ({ queryEditorId }) =>
+ queryEditorId === action.tables[0].queryEditorId,
+ )?.id ?? 'Results',
+ }),
};
},
[actions.COST_ESTIMATE_STARTED]() {
@@ -315,8 +333,6 @@ export default function sqlLabReducer(state = {}, action) {
const queries = { ...state.queries, [q.id]: q };
newState = { ...state, queries };
}
- } else {
- newState.activeSouthPaneTab = action.query.id;
}
newState = addToObject(newState, 'queries', action.query);
diff --git a/superset-frontend/src/SqlLab/types.ts
b/superset-frontend/src/SqlLab/types.ts
index eb49ca8ac1..3cff1d7eb2 100644
--- a/superset-frontend/src/SqlLab/types.ts
+++ b/superset-frontend/src/SqlLab/types.ts
@@ -86,7 +86,7 @@ export interface Table {
schema: string;
name: string;
queryEditorId: QueryEditor['id'];
- dataPreviewQueryId: string | null;
+ dataPreviewQueryId?: string | null;
expanded: boolean;
initialized?: boolean;
inLocalStorage?: boolean;
diff --git a/superset-frontend/src/components/Icons/AntdEnhanced.tsx
b/superset-frontend/src/components/Icons/AntdEnhanced.tsx
index eadd0ea2ed..423e2b39ee 100644
--- a/superset-frontend/src/components/Icons/AntdEnhanced.tsx
+++ b/superset-frontend/src/components/Icons/AntdEnhanced.tsx
@@ -40,6 +40,7 @@ import {
DashboardOutlined,
DatabaseOutlined,
DeleteFilled,
+ DownSquareOutlined,
DownOutlined,
DownloadOutlined,
EditOutlined,
@@ -100,6 +101,7 @@ const AntdIcons = {
DashboardOutlined,
DatabaseOutlined,
DeleteFilled,
+ DownSquareOutlined,
DownOutlined,
DownloadOutlined,
EditOutlined,
diff --git a/superset-frontend/src/hooks/apiResources/sqlLab.ts
b/superset-frontend/src/hooks/apiResources/sqlLab.ts
index 54e24ae136..2c2e2a8650 100644
--- a/superset-frontend/src/hooks/apiResources/sqlLab.ts
+++ b/superset-frontend/src/hooks/apiResources/sqlLab.ts
@@ -33,9 +33,11 @@ export type InitialState = {
id: number;
table: string;
description: {
- columns?: {
+ name: string;
+ columns: {
name: string;
type: string;
+ longType: string;
}[];
dataPreviewQueryId?: string;
} & Record<string, any>;
diff --git a/superset-frontend/src/hooks/apiResources/tables.ts
b/superset-frontend/src/hooks/apiResources/tables.ts
index e32113babc..81792b4a52 100644
--- a/superset-frontend/src/hooks/apiResources/tables.ts
+++ b/superset-frontend/src/hooks/apiResources/tables.ts
@@ -66,10 +66,12 @@ export type FetchTableMetadataQueryParams = {
};
type ColumnKeyTypeType = 'pk' | 'fk' | 'index';
-interface Column {
+export interface Column {
name: string;
keys?: { type: ColumnKeyTypeType }[];
type: string;
+ comment?: string;
+ longType: string;
}
export type TableMetaData = {
@@ -83,6 +85,7 @@ export type TableMetaData = {
selectStar?: string;
view?: string;
columns: Column[];
+ comment?: string;
};
type TableMetadataResponse = {
@@ -143,6 +146,9 @@ const tableApi = api.injectEndpoints({
)}`,
transformResponse: ({ json }: JsonResponse) => json,
}),
+ providesTags: (result, error, { table }) => [
+ { type: 'TableMetadatas', id: table },
+ ],
}),
}),
});
@@ -150,6 +156,8 @@ const tableApi = api.injectEndpoints({
export const {
useLazyTablesQuery,
useTablesQuery,
+ useLazyTableMetadataQuery,
+ useLazyTableExtendedMetadataQuery,
useTableMetadataQuery,
useTableExtendedMetadataQuery,
endpoints: tableEndpoints,