feat(sqllab): improve table metadata UI (#32051)

This commit is contained in:
JUST.in DO IT 2025-01-31 10:19:37 -08:00 committed by GitHub
parent 101d3fa78d
commit c590e90c87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 889 additions and 145 deletions

View File

@ -144,6 +144,13 @@ export interface SQLResultTableExtensionProps {
allowHTML?: boolean; allowHTML?: boolean;
} }
export interface SQLTablePreviewExtensionProps {
dbId: number;
catalog?: string;
schema: string;
tableName: string;
}
/** /**
* Interface for extensions to Slice Header * Interface for extensions to Slice Header
*/ */
@ -229,4 +236,8 @@ export type Extensions = Partial<{
'sqleditor.extension.customAutocomplete': ( 'sqleditor.extension.customAutocomplete': (
args: CustomAutoCompleteArgs, args: CustomAutoCompleteArgs,
) => CustomAutocomplete[] | undefined; ) => CustomAutocomplete[] | undefined;
'sqleditor.extension.tablePreview': [
string,
ComponentType<SQLTablePreviewExtensionProps>,
][];
}>; }>;

View File

@ -237,7 +237,7 @@ export function clearInactiveQueries(interval) {
return { type: CLEAR_INACTIVE_QUERIES, interval }; return { type: CLEAR_INACTIVE_QUERIES, interval };
} }
export function startQuery(query) { export function startQuery(query, runPreviewOnly) {
Object.assign(query, { Object.assign(query, {
id: query.id ? query.id : nanoid(11), id: query.id ? query.id : nanoid(11),
progress: 0, progress: 0,
@ -245,7 +245,7 @@ export function startQuery(query) {
state: query.runAsync ? 'pending' : 'running', state: query.runAsync ? 'pending' : 'running',
cached: false, cached: false,
}); });
return { type: START_QUERY, query }; return { type: START_QUERY, query, runPreviewOnly };
} }
export function querySuccess(query, results) { 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) { return function (dispatch) {
dispatch(startQuery(query)); dispatch(startQuery(query, runPreviewOnly));
const postPayload = { const postPayload = {
client_id: query.id, client_id: query.id,
database_id: query.dbId, database_id: query.dbId,
@ -947,29 +947,25 @@ export function mergeTable(table, query, prepend) {
export function addTable(queryEditor, tableName, catalogName, schemaName) { export function addTable(queryEditor, tableName, catalogName, schemaName) {
return function (dispatch, getState) { return function (dispatch, getState) {
const query = getUpToDateQuery(getState(), queryEditor, queryEditor.id); const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
const table = { const table = {
dbId: query.dbId, dbId,
queryEditorId: query.id, queryEditorId: queryEditor.id,
catalog: catalogName, catalog: catalogName,
schema: schemaName, schema: schemaName,
name: tableName, name: tableName,
}; };
dispatch( dispatch(
mergeTable( mergeTable({
{ ...table,
...table, id: nanoid(11),
id: nanoid(11), expanded: true,
expanded: true, }),
},
null,
true,
),
); );
}; };
} }
export function runTablePreviewQuery(newTable) { export function runTablePreviewQuery(newTable, runPreviewOnly) {
return function (dispatch, getState) { return function (dispatch, getState) {
const { const {
sqlLab: { databases }, sqlLab: { databases },
@ -979,7 +975,7 @@ export function runTablePreviewQuery(newTable) {
if (database && !database.disable_data_preview) { if (database && !database.disable_data_preview) {
const dataPreviewQuery = { const dataPreviewQuery = {
id: nanoid(11), id: newTable.previewQueryId ?? nanoid(11),
dbId, dbId,
catalog, catalog,
schema, schema,
@ -991,6 +987,9 @@ export function runTablePreviewQuery(newTable) {
ctas: false, ctas: false,
isDataPreview: true, isDataPreview: true,
}; };
if (runPreviewOnly) {
return dispatch(runQuery(dataPreviewQuery, runPreviewOnly));
}
return Promise.all([ return Promise.all([
dispatch( dispatch(
mergeTable( mergeTable(
@ -1024,7 +1023,7 @@ export function syncTable(table, tableMetadata) {
return sync return sync
.then(({ json: resultJson }) => { .then(({ json: resultJson }) => {
const newTable = { ...table, id: resultJson.id }; const newTable = { ...table, id: `${resultJson.id}` };
dispatch( dispatch(
mergeTable({ mergeTable({
...newTable, ...newTable,
@ -1032,9 +1031,6 @@ export function syncTable(table, tableMetadata) {
initialized: true, initialized: true,
}), }),
); );
if (!table.dataPreviewQueryId) {
dispatch(runTablePreviewQuery({ ...tableMetadata, ...newTable }));
}
}) })
.catch(() => .catch(() =>
dispatch( dispatch(

View File

@ -1031,30 +1031,38 @@ describe('async actions', () => {
}); });
describe('runTablePreviewQuery', () => { describe('runTablePreviewQuery', () => {
it('updates and runs data preview query when configured', () => { const results = {
expect.assertions(3); 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 = { beforeEach(() => {
data: mockBigNumber,
query: { sqlEditorId: 'null', dbId: 1 },
query_id: 'efgh',
};
fetchMock.post(runQueryEndpoint, JSON.stringify(results), { fetchMock.post(runQueryEndpoint, JSON.stringify(results), {
overwriteRoutes: true, 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 = [ const expectedActionTypes = [
actions.MERGE_TABLE, // addTable (data preview) actions.MERGE_TABLE, // addTable (data preview)
actions.START_QUERY, // runQuery (data preview) actions.START_QUERY, // runQuery (data preview)
@ -1075,6 +1083,30 @@ describe('async actions', () => {
expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0); 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', () => { describe('expandTable', () => {

View File

@ -16,8 +16,6 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import reducerIndex from 'spec/helpers/reducerIndex'; import reducerIndex from 'spec/helpers/reducerIndex';
import { render, waitFor, createStore } from 'spec/helpers/testing-library'; import { render, waitFor, createStore } from 'spec/helpers/testing-library';
import { QueryEditor } from 'src/SqlLab/types'; import { QueryEditor } from 'src/SqlLab/types';
@ -34,9 +32,6 @@ import {
} from 'src/SqlLab/actions/sqlLab'; } from 'src/SqlLab/actions/sqlLab';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
fetchMock.get('glob:*/api/v1/database/*/function_names/', { fetchMock.get('glob:*/api/v1/database/*/function_names/', {
function_names: [], function_names: [],
}); });
@ -79,7 +74,8 @@ describe('AceEditorWrapper', () => {
}); });
it('renders ace editor including sql value', async () => { 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()); await waitFor(() => expect(getByTestId('react-ace')).toBeInTheDocument());
expect(getByTestId('react-ace')).toHaveTextContent( expect(getByTestId('react-ace')).toHaveTextContent(
@ -89,9 +85,8 @@ describe('AceEditorWrapper', () => {
it('renders current sql for unrelated unsaved changes', () => { it('renders current sql for unrelated unsaved changes', () => {
const expectedSql = 'SELECT updated_column\nFROM updated_table\nWHERE'; const expectedSql = 'SELECT updated_column\nFROM updated_table\nWHERE';
const { getByTestId } = setup( const store = createStore(
defaultQueryEditor, {
mockStore({
...initialState, ...initialState,
sqlLab: { sqlLab: {
...initialState.sqlLab, ...initialState.sqlLab,
@ -100,8 +95,10 @@ describe('AceEditorWrapper', () => {
sql: expectedSql, sql: expectedSql,
}, },
}, },
}), },
reducerIndex,
); );
const { getByTestId } = setup(defaultQueryEditor, store);
expect(getByTestId('react-ace')).not.toHaveTextContent( expect(getByTestId('react-ace')).not.toHaveTextContent(
JSON.stringify({ value: expectedSql }).slice(1, -1), JSON.stringify({ value: expectedSql }).slice(1, -1),
@ -122,7 +119,7 @@ describe('AceEditorWrapper', () => {
queryEditorSetCursorPosition(defaultQueryEditor, updatedCursorPosition), queryEditorSetCursorPosition(defaultQueryEditor, updatedCursorPosition),
); );
expect(FullSQLEditor).toHaveBeenCalledTimes(renderCount); expect(FullSQLEditor).toHaveBeenCalledTimes(renderCount);
store.dispatch(queryEditorSetDb(defaultQueryEditor, 1)); store.dispatch(queryEditorSetDb(defaultQueryEditor, 2));
expect(FullSQLEditor).toHaveBeenCalledTimes(renderCount + 1); expect(FullSQLEditor).toHaveBeenCalledTimes(renderCount + 1);
}); });
}); });

View File

@ -202,6 +202,7 @@ test('returns column keywords among selected tables', async () => {
{ {
name: expectColumn, name: expectColumn,
type: 'VARCHAR', type: 'VARCHAR',
longType: 'VARCHAR',
}, },
], ],
}, },
@ -223,6 +224,7 @@ test('returns column keywords among selected tables', async () => {
{ {
name: unexpectedColumn, name: unexpectedColumn,
type: 'VARCHAR', type: 'VARCHAR',
longType: 'VARCHAR',
}, },
], ],
}, },

View File

@ -47,7 +47,7 @@ const SqlLabStyles = styled.div`
left: 0; left: 0;
padding: 0 ${theme.gridUnit * 2}px; padding: 0 ${theme.gridUnit * 2}px;
pre { pre:not(.code) {
padding: 0 !important; padding: 0 !important;
margin: 0; margin: 0;
border: none; border: none;

View File

@ -28,21 +28,25 @@ interface ShowSQLProps {
sql: string; sql: string;
title: string; title: string;
tooltipText: string; tooltipText: string;
triggerNode?: React.ReactNode;
} }
export default function ShowSQL({ export default function ShowSQL({
tooltipText, tooltipText,
title, title,
sql: sqlString, sql: sqlString,
triggerNode,
}: ShowSQLProps) { }: ShowSQLProps) {
return ( return (
<ModalTrigger <ModalTrigger
modalTitle={title} modalTitle={title}
triggerNode={ triggerNode={
<IconTooltip triggerNode || (
className="fa fa-eye pull-left m-l-2" <IconTooltip
tooltip={tooltipText} className="fa fa-eye pull-left m-l-2"
/> tooltip={tooltipText}
/>
)
} }
modalBody={ modalBody={
<div> <div>

View File

@ -135,7 +135,7 @@ test('should render empty result state when latestQuery is empty', () => {
expect(resultPanel).toHaveTextContent('Run a query to display results'); 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} />, { const { getAllByRole } = render(<SouthPane {...mockedProps} />, {
useRedux: true, useRedux: true,
initialState: mockState, initialState: mockState,
@ -145,7 +145,7 @@ test('should render tabs for table preview queries', () => {
expect(tabs).toHaveLength(mockState.sqlLab.tables.length + 2); expect(tabs).toHaveLength(mockState.sqlLab.tables.length + 2);
expect(tabs[0]).toHaveTextContent('Results'); expect(tabs[0]).toHaveTextContent('Results');
expect(tabs[1]).toHaveTextContent('Query history'); expect(tabs[1]).toHaveTextContent('Query history');
mockState.sqlLab.tables.forEach(({ name }, index) => { mockState.sqlLab.tables.forEach(({ name, schema }, index) => {
expect(tabs[index + 2]).toHaveTextContent(`Preview: \`${name}\``); expect(tabs[index + 2]).toHaveTextContent(`${schema}.${name}`);
}); });
}); });

View File

@ -16,24 +16,25 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { createRef, useMemo } from 'react'; import { createRef, useCallback, useMemo } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import Tabs from 'src/components/Tabs'; 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 Label from 'src/components/Label';
import Icons from 'src/components/Icons';
import { SqlLabRootState } from 'src/SqlLab/types'; import { SqlLabRootState } from 'src/SqlLab/types';
import QueryHistory from '../QueryHistory'; import QueryHistory from '../QueryHistory';
import ResultSet from '../ResultSet';
import { import {
STATUS_OPTIONS, STATUS_OPTIONS,
STATE_TYPE_MAP, STATE_TYPE_MAP,
STATUS_OPTIONS_LOCALIZED, STATUS_OPTIONS_LOCALIZED,
} from '../../constants'; } from '../../constants';
import Results from './Results'; import Results from './Results';
import TablePreview from '../TablePreview';
const TAB_HEIGHT = 130; const TAB_HEIGHT = 130;
@ -98,31 +99,45 @@ const SouthPane = ({
}), }),
shallowEqual, shallowEqual,
); );
const queries = useSelector(
({ sqlLab: { queries } }: SqlLabRootState) => Object.keys(queries),
shallowEqual,
);
const activeSouthPaneTab = const activeSouthPaneTab =
useSelector<SqlLabRootState, string>( useSelector<SqlLabRootState, string>(
state => state.sqlLab.activeSouthPaneTab as string, state => state.sqlLab.activeSouthPaneTab as string,
) ?? 'Results'; ) ?? 'Results';
const querySet = useMemo(() => new Set(queries), [queries]); const pinnedTables = useMemo(
const dataPreviewQueries = useMemo(
() => () =>
tables.filter( tables.filter(
({ dataPreviewQueryId, queryEditorId: qeId }) => ({ queryEditorId: qeId }) => String(queryEditorId) === qeId,
dataPreviewQueryId &&
queryEditorId === qeId &&
querySet.has(dataPreviewQueryId),
), ),
[queryEditorId, tables, querySet], [queryEditorId, tables],
);
const pinnedTableKeys = useMemo(
() =>
Object.fromEntries(
pinnedTables.map(({ id, dbId, catalog, schema, name }) => [
id,
[dbId, catalog, schema, name].join(':'),
]),
),
[pinnedTables],
); );
const innerTabContentHeight = height - TAB_HEIGHT; const innerTabContentHeight = height - TAB_HEIGHT;
const southPaneRef = createRef<HTMLDivElement>(); const southPaneRef = createRef<HTMLDivElement>();
const switchTab = (id: string) => { const switchTab = (id: string) => {
dispatch(setActiveSouthPaneTab(id)); 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 ? ( return offline ? (
<Label className="m-r-3" type={STATE_TYPE_MAP[STATUS_OPTIONS.offline]}> <Label className="m-r-3" type={STATE_TYPE_MAP[STATUS_OPTIONS.offline]}>
@ -136,14 +151,17 @@ const SouthPane = ({
ref={southPaneRef} ref={southPaneRef}
> >
<Tabs <Tabs
activeKey={activeSouthPaneTab} type="editable-card"
activeKey={pinnedTableKeys[activeSouthPaneTab] || activeSouthPaneTab}
className="SouthPaneTabs" className="SouthPaneTabs"
onChange={switchTab} onChange={switchTab}
id={nanoid(11)} id={nanoid(11)}
fullWidth={false} fullWidth={false}
animated={false} animated={false}
onEdit={removeTable}
hideAdd
> >
<Tabs.TabPane tab={t('Results')} key="Results"> <Tabs.TabPane tab={t('Results')} key="Results" closable={false}>
<Results <Results
height={innerTabContentHeight} height={innerTabContentHeight}
latestQueryId={latestQueryId} latestQueryId={latestQueryId}
@ -151,32 +169,37 @@ const SouthPane = ({
defaultQueryLimit={defaultQueryLimit} defaultQueryLimit={defaultQueryLimit}
/> />
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={t('Query history')} key="History"> <Tabs.TabPane tab={t('Query history')} key="History" closable={false}>
<QueryHistory <QueryHistory
queryEditorId={queryEditorId} queryEditorId={queryEditorId}
displayLimit={displayLimit} displayLimit={displayLimit}
latestQueryId={latestQueryId} latestQueryId={latestQueryId}
/> />
</Tabs.TabPane> </Tabs.TabPane>
{dataPreviewQueries.map( {pinnedTables.map(({ id, dbId, catalog, schema, name }) => (
({ name, dataPreviewQueryId }) => <Tabs.TabPane
dataPreviewQueryId && ( tab={
<Tabs.TabPane <>
tab={t('Preview: `%s`', decodeURIComponent(name))} <Icons.Table
key={dataPreviewQueryId} iconSize="s"
> css={css`
<ResultSet margin-bottom: 2px;
queryId={dataPreviewQueryId} margin-right: 4px;
visualize={false} `}
csv={false}
cache
height={innerTabContentHeight}
displayLimit={displayLimit}
defaultQueryLimit={defaultQueryLimit}
/> />
</Tabs.TabPane> {`${schema}.${decodeURIComponent(name)}`}
), </>
)} }
key={pinnedTableKeys[id]}
>
<TablePreview
dbId={dbId}
catalog={catalog}
schema={schema}
tableName={name}
/>
</Tabs.TabPane>
))}
</Tabs> </Tabs>
</StyledPane> </StyledPane>
); );

View File

@ -27,6 +27,7 @@ import {
initialState, initialState,
defaultQueryEditor, defaultQueryEditor,
extraQueryEditor1, extraQueryEditor1,
extraQueryEditor2,
} from 'src/SqlLab/fixtures'; } from 'src/SqlLab/fixtures';
import type { RootState } from 'src/views/store'; import type { RootState } from 'src/views/store';
import type { Store } from 'redux'; 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 () => { test('When changing database the schema and table list must be updated', async () => {
const { rerender } = await renderAndWait(mockedProps, undefined, { const reduxState = {
...initialState, ...initialState,
sqlLab: { sqlLab: {
...initialState.sqlLab, ...initialState.sqlLab,
unsavedQueryEditor: { unsavedQueryEditor: {
id: defaultQueryEditor.id, id: defaultQueryEditor.id,
schema: 'new_schema', schema: 'db1_schema',
}, },
queryEditors: [ queryEditors: [
defaultQueryEditor, defaultQueryEditor,
@ -223,16 +224,22 @@ test('When changing database the schema and table list must be updated', async (
}, },
], ],
tables: [ tables: [
table, {
...table,
dbId: defaultQueryEditor.dbId,
schema: 'db1_schema',
},
{ {
...table, ...table,
dbId: 2, dbId: 2,
schema: 'new_schema',
name: 'new_table', name: 'new_table',
queryEditorId: extraQueryEditor1.id, queryEditorId: extraQueryEditor1.id,
}, },
], ],
}, },
}); };
const { rerender } = await renderAndWait(mockedProps, undefined, reduxState);
expect(screen.getAllByText(/main/i)[0]).toBeInTheDocument(); expect(screen.getAllByText(/main/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/ab_user/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); const updatedDbSelector = await screen.findAllByText(/new_db/i);
expect(updatedDbSelector[0]).toBeInTheDocument(); expect(updatedDbSelector[0]).toBeInTheDocument();
const updatedTableSelector = await screen.findAllByText(/new_table/i);
expect(updatedTableSelector[0]).toBeInTheDocument();
const select = screen.getByRole('combobox', { const select = screen.getByRole('combobox', {
name: 'Select schema or type to search schemas', name: 'Select schema or type to search schemas',
}); });
userEvent.click(select); userEvent.click(select);
expect( expect(
await screen.findByRole('option', { name: 'main' }), await screen.findByRole('option', { name: 'main' }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
await screen.findByRole('option', { name: 'new_schema' }), await screen.findByRole('option', { name: 'new_schema' }),
).toBeInTheDocument(); ).toBeInTheDocument();
rerender(
<SqlEditorLeftBar userEvent.click(screen.getAllByText('new_schema')[1]);
{...mockedProps}
database={{ 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, id: 3,
database_name: 'unauth_db', database_name: 'unauth_db',
backend: 'minervasql', 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); userEvent.click(select);
expect( expect(
await screen.findByText('No compatible schema found'), await screen.findByText('No compatible schema found'),

View File

@ -101,7 +101,7 @@ const SqlEditorLeftBar = ({
queryEditorId, queryEditorId,
height = 500, height = 500,
}: SqlEditorLeftBarProps) => { }: SqlEditorLeftBarProps) => {
const tables = useSelector<SqlLabRootState, Table[]>( const allSelectedTables = useSelector<SqlLabRootState, Table[]>(
({ sqlLab }) => ({ sqlLab }) =>
sqlLab.tables.filter(table => table.queryEditorId === queryEditorId), sqlLab.tables.filter(table => table.queryEditorId === queryEditorId),
shallowEqual, shallowEqual,
@ -117,7 +117,14 @@ const SqlEditorLeftBar = ({
const [userSelectedDb, setUserSelected] = useState<DatabaseObject | null>( const [userSelectedDb, setUserSelected] = useState<DatabaseObject | null>(
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(() => { useEffect(() => {
const bool = querystring.parse(window.location.search).db; const bool = querystring.parse(window.location.search).db;

View File

@ -92,7 +92,7 @@ test('has 4 IconTooltip elements', async () => {
initialState, initialState,
}); });
await waitFor(() => 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, initialState,
}); });
await waitFor(() => await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(5), expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
); );
const style = window.getComputedStyle(getAllByTestId('fade')[0]); const style = window.getComputedStyle(getAllByTestId('fade')[0]);
expect(style.opacity).toBe('0'); expect(style.opacity).toBe('0');
@ -133,7 +133,7 @@ test('sorts columns', async () => {
}, },
); );
await waitFor(() => await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(5), expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
); );
expect( expect(
getAllByTestId('mock-column-element').map(el => el.textContent), getAllByTestId('mock-column-element').map(el => el.textContent),
@ -160,7 +160,7 @@ test('removes the table', async () => {
}, },
); );
await waitFor(() => 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(updateTableSchemaEndpoint)).toHaveLength(0);
fireEvent.click(getByText('Remove table preview')); fireEvent.click(getByText('Remove table preview'));
@ -193,7 +193,7 @@ test('refreshes table metadata when triggered', async () => {
}, },
); );
await waitFor(() => 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(updateTableSchemaEndpoint)).toHaveLength(0);
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1); expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1);

View File

@ -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(),
);
});
});

View File

@ -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;

View File

@ -38,9 +38,10 @@ export const table = {
selectStar: 'SELECT * FROM ab_user', selectStar: 'SELECT * FROM ab_user',
queryEditorId: 'dfsadfs', queryEditorId: 'dfsadfs',
catalog: null, catalog: null,
schema: 'superset', schema: 'main',
name: 'ab_user', name: 'ab_user',
id: 'r11Vgt60', id: 'r11Vgt60',
view: 'SELECT * FROM ab_user',
dataPreviewQueryId: null, dataPreviewQueryId: null,
partitions: { partitions: {
cols: ['username'], cols: ['username'],
@ -188,7 +189,7 @@ export const defaultQueryEditor = {
version: LatestQueryEditorVersion, version: LatestQueryEditorVersion,
id: 'dfsadfs', id: 'dfsadfs',
autorun: false, autorun: false,
dbId: undefined, dbId: 1,
latestQueryId: null, latestQueryId: null,
selectedText: undefined, selectedText: undefined,
sql: 'SELECT *\nFROM\nWHERE', sql: 'SELECT *\nFROM\nWHERE',

View File

@ -167,9 +167,10 @@ describe('getInitialState', () => {
table: 'table1', table: 'table1',
tab_state_id: 1, tab_state_id: 1,
description: { description: {
name: 'table1',
columns: [ columns: [
{ name: 'id', type: 'INT' }, { name: 'id', type: 'INT', longType: 'INT()' },
{ name: 'column2', type: 'STRING' }, { name: 'column2', type: 'STRING', longType: 'STRING()' },
], ],
}, },
}, },
@ -178,9 +179,10 @@ describe('getInitialState', () => {
table: 'table2', table: 'table2',
tab_state_id: 1, tab_state_id: 1,
description: { description: {
name: 'table2',
columns: [ columns: [
{ name: 'id', type: 'INT' }, { name: 'id', type: 'INT', longType: 'INT()' },
{ name: 'column2', type: 'STRING' }, { name: 'column2', type: 'STRING', longType: 'STRING()' },
], ],
}, },
}, },

View File

@ -122,12 +122,12 @@ export default function getInitialState({
.forEach(tableSchema => { .forEach(tableSchema => {
const { dataPreviewQueryId, ...persistData } = tableSchema.description; const { dataPreviewQueryId, ...persistData } = tableSchema.description;
const table = { const table = {
dbId: tableSchema.database_id, dbId: tableSchema.database_id ?? 0,
queryEditorId: tableSchema.tab_state_id.toString(), queryEditorId: tableSchema.tab_state_id.toString(),
catalog: tableSchema.catalog, catalog: tableSchema.catalog,
schema: tableSchema.schema, schema: tableSchema.schema,
name: tableSchema.table, name: tableSchema.table,
expanded: tableSchema.expanded, expanded: Boolean(tableSchema.expanded),
id: tableSchema.id, id: tableSchema.id,
dataPreviewQueryId, dataPreviewQueryId,
persistData, 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 * If the `SQLLAB_BACKEND_PERSISTENCE` feature flag is off, or if the user

View File

@ -187,30 +187,40 @@ export default function sqlLabReducer(state = {}, action) {
}, },
[actions.MERGE_TABLE]() { [actions.MERGE_TABLE]() {
const at = { ...action.table }; const at = { ...action.table };
let existingTable; const existingTableIndex = state.tables.findIndex(
state.tables.forEach(xt => { xt =>
if (
xt.dbId === at.dbId && xt.dbId === at.dbId &&
xt.queryEditorId === at.queryEditorId && xt.queryEditorId === at.queryEditorId &&
xt.catalog === at.catalog && xt.catalog === at.catalog &&
xt.schema === at.schema && xt.schema === at.schema &&
xt.name === at.name xt.name === at.name,
) { );
existingTable = xt; if (existingTableIndex >= 0) {
}
});
if (existingTable) {
if (action.query) { if (action.query) {
at.dataPreviewQueryId = action.query.id; at.dataPreviewQueryId = action.query.id;
} }
if (existingTable.initialized) { return {
at.id = existingTable.id; ...state,
} tables: [
return alterInArr(state, 'tables', existingTable, at); ...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 // for new table, associate Id of query for data preview
at.dataPreviewQueryId = null; at.dataPreviewQueryId = null;
let newState = addToArr(state, 'tables', at, Boolean(action.prepend)); let newState = addToArr(state, 'tables', at, Boolean(action.prepend));
newState.activeSouthPaneTab = at.id;
if (action.query) { if (action.query) {
newState = alterInArr(newState, 'tables', at, { newState = alterInArr(newState, 'tables', at, {
dataPreviewQueryId: action.query.id, dataPreviewQueryId: action.query.id,
@ -245,7 +255,6 @@ export default function sqlLabReducer(state = {}, action) {
...state, ...state,
queries, queries,
tables: newTables, tables: newTables,
activeSouthPaneTab: action.newQuery.id,
}; };
}, },
[actions.COLLAPSE_TABLE]() { [actions.COLLAPSE_TABLE]() {
@ -253,9 +262,18 @@ export default function sqlLabReducer(state = {}, action) {
}, },
[actions.REMOVE_TABLES]() { [actions.REMOVE_TABLES]() {
const tableIds = action.tables.map(table => table.id); const tableIds = action.tables.map(table => table.id);
const tables = state.tables.filter(table => !tableIds.includes(table.id));
return { return {
...state, ...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]() { [actions.COST_ESTIMATE_STARTED]() {
@ -315,8 +333,6 @@ export default function sqlLabReducer(state = {}, action) {
const queries = { ...state.queries, [q.id]: q }; const queries = { ...state.queries, [q.id]: q };
newState = { ...state, queries }; newState = { ...state, queries };
} }
} else {
newState.activeSouthPaneTab = action.query.id;
} }
newState = addToObject(newState, 'queries', action.query); newState = addToObject(newState, 'queries', action.query);

View File

@ -86,7 +86,7 @@ export interface Table {
schema: string; schema: string;
name: string; name: string;
queryEditorId: QueryEditor['id']; queryEditorId: QueryEditor['id'];
dataPreviewQueryId: string | null; dataPreviewQueryId?: string | null;
expanded: boolean; expanded: boolean;
initialized?: boolean; initialized?: boolean;
inLocalStorage?: boolean; inLocalStorage?: boolean;

View File

@ -40,6 +40,7 @@ import {
DashboardOutlined, DashboardOutlined,
DatabaseOutlined, DatabaseOutlined,
DeleteFilled, DeleteFilled,
DownSquareOutlined,
DownOutlined, DownOutlined,
DownloadOutlined, DownloadOutlined,
EditOutlined, EditOutlined,
@ -100,6 +101,7 @@ const AntdIcons = {
DashboardOutlined, DashboardOutlined,
DatabaseOutlined, DatabaseOutlined,
DeleteFilled, DeleteFilled,
DownSquareOutlined,
DownOutlined, DownOutlined,
DownloadOutlined, DownloadOutlined,
EditOutlined, EditOutlined,

View File

@ -33,9 +33,11 @@ export type InitialState = {
id: number; id: number;
table: string; table: string;
description: { description: {
columns?: { name: string;
columns: {
name: string; name: string;
type: string; type: string;
longType: string;
}[]; }[];
dataPreviewQueryId?: string; dataPreviewQueryId?: string;
} & Record<string, any>; } & Record<string, any>;

View File

@ -66,10 +66,12 @@ export type FetchTableMetadataQueryParams = {
}; };
type ColumnKeyTypeType = 'pk' | 'fk' | 'index'; type ColumnKeyTypeType = 'pk' | 'fk' | 'index';
interface Column { export interface Column {
name: string; name: string;
keys?: { type: ColumnKeyTypeType }[]; keys?: { type: ColumnKeyTypeType }[];
type: string; type: string;
comment?: string;
longType: string;
} }
export type TableMetaData = { export type TableMetaData = {
@ -83,6 +85,7 @@ export type TableMetaData = {
selectStar?: string; selectStar?: string;
view?: string; view?: string;
columns: Column[]; columns: Column[];
comment?: string;
}; };
type TableMetadataResponse = { type TableMetadataResponse = {
@ -143,6 +146,9 @@ const tableApi = api.injectEndpoints({
)}`, )}`,
transformResponse: ({ json }: JsonResponse) => json, transformResponse: ({ json }: JsonResponse) => json,
}), }),
providesTags: (result, error, { table }) => [
{ type: 'TableMetadatas', id: table },
],
}), }),
}), }),
}); });
@ -150,6 +156,8 @@ const tableApi = api.injectEndpoints({
export const { export const {
useLazyTablesQuery, useLazyTablesQuery,
useTablesQuery, useTablesQuery,
useLazyTableMetadataQuery,
useLazyTableExtendedMetadataQuery,
useTableMetadataQuery, useTableMetadataQuery,
useTableExtendedMetadataQuery, useTableExtendedMetadataQuery,
endpoints: tableEndpoints, endpoints: tableEndpoints,