mirror of
https://github.com/apache/superset.git
synced 2026-06-16 13:09:20 +00:00
Compare commits
1 Commits
fix-report
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d136bc000 |
@@ -1420,39 +1420,6 @@ describe('async actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('syncTable', () => {
|
||||
test('updates the table schema state in the backend', () => {
|
||||
expect.assertions(4);
|
||||
|
||||
const tableName = 'table';
|
||||
const schemaName = 'schema';
|
||||
const store = mockStore(initialState);
|
||||
const expectedActionTypes = [
|
||||
actions.MERGE_TABLE, // syncTable
|
||||
];
|
||||
const request = actions.syncTable(
|
||||
query as any,
|
||||
tableName as any,
|
||||
schemaName,
|
||||
);
|
||||
return request(store.dispatch, store.getState, undefined).then(() => {
|
||||
expect(store.getActions().map(a => a.type)).toEqual(
|
||||
expectedActionTypes,
|
||||
);
|
||||
expect(store.getActions()[0].prepend).toBeFalsy();
|
||||
expect(
|
||||
fetchMock.callHistory.calls(updateTableSchemaEndpoint),
|
||||
).toHaveLength(1);
|
||||
|
||||
// tab state is not updated, since no query was run
|
||||
expect(
|
||||
fetchMock.callHistory.calls(updateTabStateEndpoint),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('runTablePreviewQuery', () => {
|
||||
const results = {
|
||||
|
||||
@@ -1346,52 +1346,6 @@ export function runTablePreviewQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export interface TableMetaData {
|
||||
columns?: unknown[];
|
||||
selectStar?: string;
|
||||
primaryKey?: unknown;
|
||||
foreignKeys?: unknown[];
|
||||
indexes?: unknown[];
|
||||
}
|
||||
|
||||
export function syncTable(
|
||||
table: Table,
|
||||
tableMetadata: TableMetaData,
|
||||
finalQueryEditorId?: string,
|
||||
): SqlLabThunkAction<Promise<unknown>> {
|
||||
return function (dispatch: AppDispatch) {
|
||||
const finalTable = { ...table, queryEditorId: finalQueryEditorId };
|
||||
const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
|
||||
? SupersetClient.post({
|
||||
endpoint: encodeURI('/tableschemaview/'),
|
||||
postPayload: { table: { ...tableMetadata, ...finalTable } },
|
||||
})
|
||||
: Promise.resolve({ json: { id: table.id } });
|
||||
|
||||
return sync
|
||||
.then(({ json: resultJson }) => {
|
||||
const newTable = { ...table, id: `${resultJson.id}` };
|
||||
dispatch(
|
||||
mergeTable({
|
||||
...newTable,
|
||||
expanded: true,
|
||||
initialized: true,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t(
|
||||
'An error occurred while fetching table metadata. ' +
|
||||
'Please contact your administrator.',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function changeDataPreviewId(
|
||||
oldQueryId: string,
|
||||
newQuery: Query,
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
/**
|
||||
* 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 { isValidElement } from 'react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import TableElement, { Column } from 'src/SqlLab/components/TableElement';
|
||||
import { table, initialState } from 'src/SqlLab/fixtures';
|
||||
import { render, waitFor, fireEvent } from 'spec/helpers/testing-library';
|
||||
import * as sqlLabActions from 'src/SqlLab/actions/sqlLab';
|
||||
import { QueryEditor } from 'src/SqlLab/types';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
jest.mock('@superset-ui/core/components/Loading', () => ({
|
||||
Loading: () => <div data-test="mock-loading" />,
|
||||
}));
|
||||
jest.mock('@superset-ui/core/components/IconTooltip', () => ({
|
||||
IconTooltip: ({
|
||||
onClick,
|
||||
tooltip,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
tooltip: string;
|
||||
}) => (
|
||||
<button type="button" data-test="mock-icon-tooltip" onClick={onClick}>
|
||||
{tooltip}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
jest.mock(
|
||||
'src/SqlLab/components/ColumnElement',
|
||||
() =>
|
||||
({ column }: { column: Column }) => (
|
||||
<div data-test="mock-column-element">{column.name}</div>
|
||||
),
|
||||
);
|
||||
const getTableMetadataEndpoint =
|
||||
/\/api\/v1\/database\/\d+\/table_metadata\/(?:\?.*)?$/;
|
||||
const getExtraTableMetadataEndpoint =
|
||||
/\/api\/v1\/database\/\d+\/table_metadata\/extra\/(?:\?.*)?$/;
|
||||
const updateTableSchemaExpandedEndpoint = 'glob:*/tableschemaview/*/expanded';
|
||||
const updateTableSchemaEndpoint = 'glob:*/tableschemaview/';
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(getTableMetadataEndpoint, table);
|
||||
fetchMock.get(getExtraTableMetadataEndpoint, {});
|
||||
fetchMock.post(updateTableSchemaExpandedEndpoint, {});
|
||||
fetchMock.post(updateTableSchemaEndpoint, {});
|
||||
});
|
||||
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
const mockedProps = {
|
||||
table: {
|
||||
...table,
|
||||
initialized: true,
|
||||
},
|
||||
activeKey: [table.id],
|
||||
};
|
||||
|
||||
const createStateWithQueryEditor = (queryEditor: Partial<QueryEditor>) => ({
|
||||
...initialState,
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queryEditors: [queryEditor],
|
||||
},
|
||||
});
|
||||
|
||||
const setupSyncTableTest = () => {
|
||||
const spy = jest.spyOn(sqlLabActions, 'syncTable');
|
||||
mockedIsFeatureEnabled.mockImplementation(
|
||||
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
|
||||
);
|
||||
fetchMock.removeRoute(updateTableSchemaEndpoint);
|
||||
fetchMock.post(
|
||||
updateTableSchemaEndpoint,
|
||||
{ id: 100 },
|
||||
{ name: updateTableSchemaEndpoint },
|
||||
);
|
||||
return spy;
|
||||
};
|
||||
|
||||
test('renders', () => {
|
||||
expect(isValidElement(<TableElement table={table} />)).toBe(true);
|
||||
});
|
||||
|
||||
test('renders with props', () => {
|
||||
expect(isValidElement(<TableElement {...mockedProps} />)).toBe(true);
|
||||
});
|
||||
|
||||
test('has 4 IconTooltip elements', async () => {
|
||||
const { getAllByTestId } = render(<TableElement {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
initialState,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
|
||||
);
|
||||
});
|
||||
|
||||
test('has 14 columns', async () => {
|
||||
const { getAllByTestId } = render(<TableElement {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
initialState,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(getAllByTestId('mock-column-element')).toHaveLength(14),
|
||||
);
|
||||
});
|
||||
|
||||
test('fades table', async () => {
|
||||
const { getAllByTestId } = render(<TableElement {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
initialState,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
|
||||
);
|
||||
const style = window.getComputedStyle(getAllByTestId('fade')[0]);
|
||||
expect(style.opacity).toBe('0');
|
||||
fireEvent.mouseEnter(getAllByTestId('table-element-header-container')[0]);
|
||||
await waitFor(() =>
|
||||
expect(window.getComputedStyle(getAllByTestId('fade')[0]).opacity).toBe(
|
||||
'1',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('sorts columns', async () => {
|
||||
const { getAllByTestId, getByText } = render(
|
||||
<TableElement {...mockedProps} />,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState,
|
||||
},
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
|
||||
);
|
||||
expect(
|
||||
getAllByTestId('mock-column-element').map(el => el.textContent),
|
||||
).toEqual(table.columns.map(col => col.name));
|
||||
fireEvent.click(getByText('Sort columns alphabetically'));
|
||||
const sorted = table.columns.map(col => col.name).sort();
|
||||
expect(
|
||||
getAllByTestId('mock-column-element').map(el => el.textContent),
|
||||
).toEqual(sorted);
|
||||
expect(getAllByTestId('mock-column-element')[0]).toHaveTextContent('active');
|
||||
});
|
||||
|
||||
test('removes the table', async () => {
|
||||
const updateTableSchemaEndpoint = 'glob:*/tableschemaview/*';
|
||||
fetchMock.delete(updateTableSchemaEndpoint, {});
|
||||
mockedIsFeatureEnabled.mockImplementation(
|
||||
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
|
||||
);
|
||||
const { getAllByTestId, getByText } = render(
|
||||
<TableElement {...mockedProps} />,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState,
|
||||
},
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
|
||||
);
|
||||
expect(fetchMock.callHistory.calls(updateTableSchemaEndpoint)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
fireEvent.click(getByText('Remove table preview'));
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.callHistory.calls(updateTableSchemaEndpoint)).toHaveLength(
|
||||
1,
|
||||
),
|
||||
);
|
||||
mockedIsFeatureEnabled.mockClear();
|
||||
});
|
||||
|
||||
test('fetches table metadata when expanded', async () => {
|
||||
render(<TableElement {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
initialState,
|
||||
});
|
||||
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(0);
|
||||
expect(
|
||||
fetchMock.callHistory.calls(getExtraTableMetadataEndpoint),
|
||||
).toHaveLength(0);
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(
|
||||
1,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
fetchMock.callHistory.calls(updateTableSchemaExpandedEndpoint),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
fetchMock.callHistory.calls(getExtraTableMetadataEndpoint),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('refreshes table metadata when triggered', async () => {
|
||||
const { getAllByTestId, getByText } = render(
|
||||
<TableElement {...mockedProps} />,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState,
|
||||
},
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(6),
|
||||
);
|
||||
expect(fetchMock.callHistory.calls(updateTableSchemaEndpoint)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(1);
|
||||
|
||||
fireEvent.click(getByText('Refresh table schema'));
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(
|
||||
2,
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.callHistory.calls(updateTableSchemaEndpoint)).toHaveLength(
|
||||
1,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('calls syncTable with valid backend ID when query editor has tabViewId', async () => {
|
||||
const syncTableSpy = setupSyncTableTest();
|
||||
const testTable = {
|
||||
...table,
|
||||
initialized: false,
|
||||
queryEditorId: 'temp-id-123',
|
||||
};
|
||||
|
||||
const state = createStateWithQueryEditor({
|
||||
id: 'temp-id-123',
|
||||
tabViewId: '42',
|
||||
inLocalStorage: false,
|
||||
name: 'Test Editor',
|
||||
});
|
||||
|
||||
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
|
||||
useRedux: true,
|
||||
initialState: state,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(syncTableSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
'42', // finalQueryEditorId
|
||||
);
|
||||
});
|
||||
|
||||
syncTableSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not call syncTable when query editor is in localStorage', async () => {
|
||||
const syncTableSpy = setupSyncTableTest();
|
||||
const testTable = {
|
||||
...table,
|
||||
initialized: false,
|
||||
queryEditorId: 'local-id',
|
||||
};
|
||||
|
||||
const state = createStateWithQueryEditor({
|
||||
id: 'local-id',
|
||||
tabViewId: undefined,
|
||||
inLocalStorage: true,
|
||||
name: 'Local Editor',
|
||||
});
|
||||
|
||||
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
|
||||
useRedux: true,
|
||||
initialState: state,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(syncTableSpy).not.toHaveBeenCalled();
|
||||
|
||||
syncTableSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not call syncTable with non-numeric queryEditorId', async () => {
|
||||
const syncTableSpy = setupSyncTableTest();
|
||||
const testTable = {
|
||||
...table,
|
||||
initialized: false,
|
||||
queryEditorId: 'not-a-number',
|
||||
};
|
||||
|
||||
const state = createStateWithQueryEditor({
|
||||
id: 'not-a-number',
|
||||
tabViewId: 'also-not-a-number',
|
||||
inLocalStorage: false,
|
||||
name: 'Invalid Editor',
|
||||
});
|
||||
|
||||
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
|
||||
useRedux: true,
|
||||
initialState: state,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(syncTableSpy).not.toHaveBeenCalled();
|
||||
|
||||
syncTableSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not call syncTable for already initialized tables', async () => {
|
||||
const syncTableSpy = setupSyncTableTest();
|
||||
const testTable = {
|
||||
...table,
|
||||
initialized: true, // Already initialized
|
||||
queryEditorId: '789',
|
||||
};
|
||||
|
||||
const state = createStateWithQueryEditor({
|
||||
id: '789',
|
||||
tabViewId: '789',
|
||||
inLocalStorage: false,
|
||||
name: 'Initialized Editor',
|
||||
});
|
||||
|
||||
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
|
||||
useRedux: true,
|
||||
initialState: state,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.callHistory.calls(getTableMetadataEndpoint)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(syncTableSpy).not.toHaveBeenCalled();
|
||||
|
||||
syncTableSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('calls syncTable after query editor is migrated from localStorage', async () => {
|
||||
const syncTableSpy = setupSyncTableTest();
|
||||
const testTable = {
|
||||
...table,
|
||||
initialized: false,
|
||||
queryEditorId: 'temp-editor-id',
|
||||
};
|
||||
|
||||
// Start with editor in localStorage
|
||||
const localState = createStateWithQueryEditor({
|
||||
id: 'temp-editor-id',
|
||||
tabViewId: undefined,
|
||||
inLocalStorage: true,
|
||||
name: 'Temp Editor',
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<TableElement table={testTable} activeKey={[testTable.id]} />,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: localState,
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(syncTableSpy).not.toHaveBeenCalled();
|
||||
|
||||
const migratedState = createStateWithQueryEditor({
|
||||
id: 'temp-editor-id',
|
||||
tabViewId: '999',
|
||||
inLocalStorage: false,
|
||||
name: 'Temp Editor',
|
||||
});
|
||||
|
||||
rerender(<TableElement table={testTable} activeKey={[testTable.id]} />);
|
||||
|
||||
const { unmount } = render(
|
||||
<TableElement table={testTable} activeKey={[testTable.id]} />,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: migratedState,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(syncTableSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
'999',
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
syncTableSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('passes numeric queryEditorId validation', async () => {
|
||||
const syncTableSpy = setupSyncTableTest();
|
||||
const testTable = {
|
||||
...table,
|
||||
initialized: false,
|
||||
queryEditorId: 'editor-123',
|
||||
};
|
||||
|
||||
const state = createStateWithQueryEditor({
|
||||
id: 'editor-123',
|
||||
tabViewId: '456',
|
||||
inLocalStorage: false,
|
||||
name: 'Valid Editor',
|
||||
});
|
||||
|
||||
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
|
||||
useRedux: true,
|
||||
initialState: state,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(syncTableSpy).toHaveBeenCalled();
|
||||
const [, , finalQueryEditorId] = syncTableSpy.mock.calls[0];
|
||||
// Verify it's a valid numeric string
|
||||
expect(Number.isNaN(Number(finalQueryEditorId))).toBe(false);
|
||||
expect(typeof finalQueryEditorId).toBe('string');
|
||||
expect(finalQueryEditorId).toMatch(/^\d+$/);
|
||||
});
|
||||
|
||||
syncTableSpy.mockRestore();
|
||||
});
|
||||
@@ -1,420 +0,0 @@
|
||||
/**
|
||||
* 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 { useState, useRef, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
|
||||
import {
|
||||
ButtonGroup,
|
||||
Card,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Flex,
|
||||
IconTooltip,
|
||||
Loading,
|
||||
ModalTrigger,
|
||||
type CollapseProps,
|
||||
} from '@superset-ui/core/components';
|
||||
import { CopyToClipboard } from 'src/components';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import {
|
||||
removeDataPreview,
|
||||
removeTables,
|
||||
addDangerToast,
|
||||
syncTable,
|
||||
} from 'src/SqlLab/actions/sqlLab';
|
||||
import {
|
||||
tableApiUtil,
|
||||
useTableExtendedMetadataQuery,
|
||||
useTableMetadataQuery,
|
||||
} from 'src/hooks/apiResources';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import { ActionType } from 'src/types/Action';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Space } from '@superset-ui/core/components/Space';
|
||||
import ColumnElement, { ColumnKeyTypeType } from '../ColumnElement';
|
||||
import ShowSQL from '../ShowSQL';
|
||||
|
||||
export interface Column {
|
||||
name: string;
|
||||
keys?: { type: ColumnKeyTypeType }[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TableElementProps extends CollapseProps {
|
||||
table: Table;
|
||||
}
|
||||
|
||||
const StyledSpan = styled.span`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Fade = styled.div`
|
||||
transition: all ${({ theme }) => theme.motionDurationMid};
|
||||
opacity: ${(props: { hovered: boolean }) => (props.hovered ? 1 : 0)};
|
||||
`;
|
||||
|
||||
const TableElement = ({ table, ...props }: TableElementProps) => {
|
||||
const { dbId, catalog, schema, name, expanded, id } = table;
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const {
|
||||
currentData: tableMetadata,
|
||||
isSuccess: isMetadataSuccess,
|
||||
isFetching: isMetadataFetching,
|
||||
isError: hasMetadataError,
|
||||
} = useTableMetadataQuery(
|
||||
{
|
||||
dbId,
|
||||
catalog,
|
||||
schema,
|
||||
table: name,
|
||||
},
|
||||
{ skip: !expanded },
|
||||
);
|
||||
const {
|
||||
currentData: tableExtendedMetadata,
|
||||
isSuccess: isExtraMetadataSuccess,
|
||||
isLoading: isExtraMetadataLoading,
|
||||
isError: hasExtendedMetadataError,
|
||||
} = useTableExtendedMetadataQuery(
|
||||
{
|
||||
dbId,
|
||||
catalog,
|
||||
schema,
|
||||
table: name,
|
||||
},
|
||||
{ skip: !expanded },
|
||||
);
|
||||
const tableData = {
|
||||
...tableMetadata,
|
||||
...tableExtendedMetadata,
|
||||
};
|
||||
const queryEditors = useSelector<SqlLabRootState, QueryEditor[]>(
|
||||
state => state.sqlLab.queryEditors,
|
||||
);
|
||||
const currentTable = { ...tableData, ...table };
|
||||
const { queryEditorId } = currentTable;
|
||||
const queryEditor = queryEditors.find(
|
||||
qe => qe.id === queryEditorId || qe.tabViewId === queryEditorId,
|
||||
);
|
||||
const currentQueryEditorId = queryEditor?.tabViewId || queryEditorId;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMetadataError || hasExtendedMetadataError) {
|
||||
dispatch(
|
||||
addDangerToast(t('An error occurred while fetching table metadata')),
|
||||
);
|
||||
}
|
||||
}, [hasMetadataError, hasExtendedMetadataError, dispatch]);
|
||||
|
||||
// TODO: migrate syncTable logic by SIP-93
|
||||
const syncTableMetadata = useEffectEvent(() => {
|
||||
const { initialized } = table;
|
||||
// if not a valid number, wait for backend to assign one
|
||||
const hasFinalQueryEditorId =
|
||||
currentQueryEditorId &&
|
||||
!Number.isNaN(Number(currentQueryEditorId)) &&
|
||||
currentTable.queryEditorId !== currentQueryEditorId;
|
||||
if (!initialized && hasFinalQueryEditorId) {
|
||||
dispatch(syncTable(currentTable, tableData, currentQueryEditorId));
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isMetadataSuccess && isExtraMetadataSuccess) {
|
||||
syncTableMetadata();
|
||||
}
|
||||
}, [
|
||||
isMetadataSuccess,
|
||||
isExtraMetadataSuccess,
|
||||
currentQueryEditorId,
|
||||
syncTableMetadata,
|
||||
]);
|
||||
|
||||
const [sortColumns, setSortColumns] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const tableNameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setHover = (hovered: boolean) => {
|
||||
debounce(() => setHovered(hovered), 100)();
|
||||
};
|
||||
|
||||
const removeTable = () => {
|
||||
dispatch(removeDataPreview(table));
|
||||
dispatch(removeTables([table]));
|
||||
};
|
||||
|
||||
const toggleSortColumns = () => {
|
||||
setSortColumns(prevState => !prevState);
|
||||
};
|
||||
|
||||
const refreshTableMetadata = () => {
|
||||
dispatch(
|
||||
tableApiUtil.invalidateTags([{ type: 'TableMetadatas', id: name }]),
|
||||
);
|
||||
dispatch(syncTable(table, tableData, table.queryEditorId));
|
||||
};
|
||||
|
||||
const renderWell = () => {
|
||||
let partitions;
|
||||
let metadata;
|
||||
if (tableData.partitions) {
|
||||
let partitionQuery;
|
||||
let partitionClipBoard;
|
||||
if (tableData.partitions.partitionQuery) {
|
||||
({ partitionQuery } = tableData.partitions);
|
||||
const tt = t('Copy partition query to clipboard');
|
||||
partitionClipBoard = (
|
||||
<CopyToClipboard
|
||||
text={partitionQuery}
|
||||
shouldShowText={false}
|
||||
tooltipText={tt}
|
||||
copyNode={<Icons.CopyOutlined iconSize="s" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const latest = Object.entries(tableData.partitions?.latest || [])
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('/');
|
||||
|
||||
partitions = (
|
||||
<div>
|
||||
<small>
|
||||
{t('latest partition:')} {latest}
|
||||
</small>{' '}
|
||||
{partitionClipBoard}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tableData.metadata) {
|
||||
metadata = Object.entries(tableData.metadata).map(([key, value]) => (
|
||||
<div>
|
||||
<small>
|
||||
<strong>{key}:</strong> {value}
|
||||
</small>
|
||||
</div>
|
||||
));
|
||||
if (!metadata?.length) {
|
||||
// hide metadata card view
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!partitions) {
|
||||
// hide partition card view
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{partitions}
|
||||
{metadata}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const renderControls = () => {
|
||||
let keyLink;
|
||||
const KEYS_FOR_TABLE_TEXT = t('Keys for table');
|
||||
if (tableData?.indexes?.length) {
|
||||
keyLink = (
|
||||
<ModalTrigger
|
||||
modalTitle={`${KEYS_FOR_TABLE_TEXT} ${name}`}
|
||||
modalBody={tableData.indexes.map((ix, i) => (
|
||||
<pre key={i}>{JSON.stringify(ix, null, ' ')}</pre>
|
||||
))}
|
||||
triggerNode={
|
||||
<IconTooltip
|
||||
className="pull-left"
|
||||
tooltip={t('View keys & indexes (%s)', tableData.indexes.length)}
|
||||
>
|
||||
<Icons.TableOutlined
|
||||
iconSize="m"
|
||||
iconColor={theme.colorPrimary}
|
||||
/>
|
||||
</IconTooltip>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flex style={{ height: 22 }} align="center">
|
||||
{isMetadataFetching || isExtraMetadataLoading ? (
|
||||
<Loading position="inline" />
|
||||
) : (
|
||||
<Fade
|
||||
data-test="fade"
|
||||
hovered={hovered}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ButtonGroup>
|
||||
<Space size="small">
|
||||
<IconTooltip
|
||||
className="pull-left pointer"
|
||||
onClick={refreshTableMetadata}
|
||||
tooltip={t('Refresh table schema')}
|
||||
>
|
||||
<Icons.SyncOutlined
|
||||
iconSize="m"
|
||||
iconColor={theme.colorIcon}
|
||||
/>
|
||||
</IconTooltip>
|
||||
{keyLink}
|
||||
<IconTooltip
|
||||
onClick={toggleSortColumns}
|
||||
tooltip={
|
||||
sortColumns
|
||||
? t('Original table column order')
|
||||
: t('Sort columns alphabetically')
|
||||
}
|
||||
>
|
||||
<Icons.SortAscendingOutlined
|
||||
iconSize="m"
|
||||
aria-hidden
|
||||
iconColor={
|
||||
sortColumns ? theme.colorIcon : theme.colorTextDisabled
|
||||
}
|
||||
/>
|
||||
</IconTooltip>
|
||||
{tableData.selectStar && (
|
||||
<CopyToClipboard
|
||||
copyNode={
|
||||
<IconTooltip
|
||||
aria-label={t('Copy')}
|
||||
tooltip={t('Copy SELECT statement to the clipboard')}
|
||||
>
|
||||
<Icons.CopyOutlined
|
||||
iconSize="m"
|
||||
aria-hidden
|
||||
iconColor={theme.colorIcon}
|
||||
/>
|
||||
</IconTooltip>
|
||||
}
|
||||
text={tableData.selectStar}
|
||||
shouldShowText={false}
|
||||
/>
|
||||
)}
|
||||
{tableData.view && (
|
||||
<ShowSQL
|
||||
sql={tableData.view}
|
||||
tooltipText={t('Show CREATE VIEW statement')}
|
||||
title={t('CREATE VIEW statement')}
|
||||
/>
|
||||
)}
|
||||
<IconTooltip
|
||||
className=" table-remove pull-left pointer"
|
||||
onClick={removeTable}
|
||||
tooltip={t('Remove table preview')}
|
||||
>
|
||||
<Icons.CloseOutlined
|
||||
iconSize="m"
|
||||
aria-hidden
|
||||
iconColor={theme.colorIcon}
|
||||
/>
|
||||
</IconTooltip>
|
||||
</Space>
|
||||
</ButtonGroup>
|
||||
</Fade>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
const element: HTMLInputElement | null = tableNameRef.current;
|
||||
let trigger = [] as ActionType[];
|
||||
if (element && element.offsetWidth < element.scrollWidth) {
|
||||
trigger = ['hover'];
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="table-element-header-container"
|
||||
className="clearfix header-container"
|
||||
>
|
||||
<Tooltip
|
||||
id="copy-to-clipboard-tooltip"
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={name}
|
||||
trigger={trigger}
|
||||
>
|
||||
<StyledSpan
|
||||
data-test="collapse"
|
||||
ref={tableNameRef}
|
||||
className="table-name"
|
||||
>
|
||||
<strong>{name}</strong>
|
||||
</StyledSpan>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
let cols;
|
||||
if (tableData.columns) {
|
||||
cols = tableData.columns.slice();
|
||||
if (sortColumns) {
|
||||
cols.sort((a: Column, b: Column) => {
|
||||
const colA = a.name.toUpperCase();
|
||||
const colB = b.name.toUpperCase();
|
||||
return colA < colB ? -1 : colA > colB ? 1 : 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = (
|
||||
<div data-test="table-element" css={{ paddingTop: 6 }}>
|
||||
{renderWell()}
|
||||
<div>
|
||||
{cols?.map(col => (
|
||||
<ColumnElement column={col} key={col.name} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return metadata;
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
activeKey={props.activeKey}
|
||||
expandIconPosition="end"
|
||||
onChange={props.onChange}
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: id,
|
||||
label: renderHeader(),
|
||||
children: renderBody(),
|
||||
extra: renderControls(),
|
||||
onMouseEnter: () => setHover(true),
|
||||
onMouseLeave: () => setHover(false),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableElement;
|
||||
Reference in New Issue
Block a user