feat(sqllab): improve table metadata UI (#32051)
This commit is contained in:
parent
101d3fa78d
commit
c590e90c87
|
|
@ -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>,
|
||||||
|
][];
|
||||||
}>;
|
}>;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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()' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue