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,

Reply via email to