chore(sqllab): Remove table metadata from state (#24371)

This commit is contained in:
JUST.in DO IT
2023-06-22 15:37:03 -07:00
committed by GitHub
parent 2a4ef5cccf
commit 51a34d7d58
14 changed files with 515 additions and 410 deletions

View File

@@ -51,7 +51,6 @@ const setup = (queryEditor: QueryEditor, store?: Store) =>
queryEditorId={queryEditor.id}
height="100px"
hotkeys={[]}
database={{}}
onChange={jest.fn()}
onBlur={jest.fn()}
autocomplete

View File

@@ -55,7 +55,6 @@ type AceEditorWrapperProps = {
onBlur: (sql: string) => void;
onChange: (sql: string) => void;
queryEditorId: string;
database: any;
extendedTables?: Array<{ name: string; columns: any[] }>;
height: string;
hotkeys: HotKey[];
@@ -86,7 +85,6 @@ const AceEditorWrapper = ({
onBlur = () => {},
onChange = () => {},
queryEditorId,
database,
extendedTables = [],
height,
hotkeys,
@@ -258,9 +256,7 @@ const AceEditorWrapper = ({
const completer = {
insertMatch: (editor: Editor, data: any) => {
if (data.meta === 'table') {
dispatch(
addTable(queryEditor, database, data.value, queryEditor.schema),
);
dispatch(addTable(queryEditor, data.value, queryEditor.schema));
}
let { caption } = data;

View File

@@ -666,7 +666,6 @@ const SqlEditor = ({
onBlur={setQueryEditorAndSaveSql}
onChange={onSqlChanged}
queryEditorId={queryEditor.id}
database={database}
extendedTables={tables}
height={`${aceEditorHeight}px`}
hotkeys={hotkeys}

View File

@@ -164,9 +164,9 @@ const SqlEditorLeftBar = ({
return true;
});
tablesToAdd.forEach(tableName =>
dispatch(addTable(queryEditor, database, tableName, schemaName)),
);
tablesToAdd.forEach(tableName => {
dispatch(addTable(queryEditor, tableName, schemaName));
});
dispatch(removeTables(currentTables));
};

View File

@@ -1,161 +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 React from 'react';
import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import Collapse from 'src/components/Collapse';
import { IconTooltip } from 'src/components/IconTooltip';
import TableElement from 'src/SqlLab/components/TableElement';
import ColumnElement from 'src/SqlLab/components/ColumnElement';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { initialState, table } from 'src/SqlLab/fixtures';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
describe('TableElement', () => {
const store = mockStore(initialState);
const mockedProps = {
table,
timeout: 0,
};
it('renders', () => {
expect(React.isValidElement(<TableElement />)).toBe(true);
});
it('renders with props', () => {
expect(React.isValidElement(<TableElement {...mockedProps} />)).toBe(true);
});
it('has 5 IconTooltip elements', () => {
const wrapper = mount(
<Provider store={store}>
<TableElement {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
expect(wrapper.find(IconTooltip)).toHaveLength(4);
});
it('has 14 columns', () => {
const wrapper = mount(
<Provider store={store}>
<TableElement {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
expect(wrapper.find(ColumnElement)).toHaveLength(14);
});
it('mounts', () => {
const wrapper = mount(
<Provider store={store}>
<TableElement {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
expect(wrapper.find(TableElement)).toHaveLength(1);
});
it('fades table', async () => {
const wrapper = mount(
<Provider store={store}>
<TableElement {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
expect(wrapper.find('[data-test="fade"]').first().props().hovered).toBe(
false,
);
wrapper.find('.header-container').hostNodes().simulate('mouseEnter');
await waitForComponentToPaint(wrapper, 300);
expect(wrapper.find('[data-test="fade"]').first().props().hovered).toBe(
true,
);
});
it('sorts columns', () => {
const wrapper = mount(
<Provider store={store}>
<Collapse>
<TableElement {...mockedProps} />
</Collapse>
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
expect(
wrapper.find(IconTooltip).at(1).hasClass('fa-sort-alpha-asc'),
).toEqual(true);
expect(
wrapper.find(IconTooltip).at(1).hasClass('fa-sort-numeric-asc'),
).toEqual(false);
wrapper.find('.header-container').hostNodes().simulate('click');
expect(wrapper.find(ColumnElement).first().props().column.name).toBe('id');
wrapper.find('.header-container').simulate('mouseEnter');
wrapper.find('.sort-cols').hostNodes().simulate('click');
expect(
wrapper.find(IconTooltip).at(1).hasClass('fa-sort-numeric-asc'),
).toEqual(true);
expect(
wrapper.find(IconTooltip).at(1).hasClass('fa-sort-alpha-asc'),
).toEqual(false);
expect(wrapper.find(ColumnElement).first().props().column.name).toBe(
'active',
);
});
it('removes the table', () => {
const wrapper = mount(
<Provider store={store}>
<TableElement {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
wrapper.find('.table-remove').hostNodes().simulate('click');
expect(store.getActions()).toHaveLength(1);
expect(store.getActions()[0].type).toEqual('REMOVE_DATA_PREVIEW');
});
});

View File

@@ -0,0 +1,177 @@
/**
* 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 React from 'react';
import fetchMock from 'fetch-mock';
import * as featureFlags from 'src/featureFlags';
import { FeatureFlag } 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';
jest.mock('src/components/Loading', () => () => (
<div data-test="mock-loading" />
));
jest.mock('src/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 = 'glob:**/api/v1/database/*/table/*/*/';
const getExtraTableMetadataEndpoint =
'glob:**/api/v1/database/*/table_extra/*/*/';
const updateTableSchemaEndpoint = 'glob:*/tableschemaview/*/expanded';
beforeEach(() => {
fetchMock.get(getTableMetadataEndpoint, table);
fetchMock.get(getExtraTableMetadataEndpoint, {});
fetchMock.post(updateTableSchemaEndpoint, {});
});
afterEach(() => {
fetchMock.reset();
});
const mockedProps = {
table: {
...table,
initialized: true,
},
};
test('renders', () => {
expect(React.isValidElement(<TableElement table={table} />)).toBe(true);
});
test('renders with props', () => {
expect(React.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(4),
);
});
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(4),
);
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(4),
);
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, {});
const isFeatureEnabledMock = jest
.spyOn(featureFlags, 'isFeatureEnabled')
.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SQLLAB_BACKEND_PERSISTENCE,
);
const { getAllByTestId, getByText } = render(
<TableElement {...mockedProps} />,
{
useRedux: true,
initialState,
},
);
await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(4),
);
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(0);
fireEvent.click(getByText('Remove table preview'));
await waitFor(() =>
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1),
);
isFeatureEnabledMock.mockClear();
});
test('fetches table metadata when expanded', async () => {
render(<TableElement {...mockedProps} />, {
useRedux: true,
initialState,
});
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(0);
expect(fetchMock.calls(getExtraTableMetadataEndpoint)).toHaveLength(0);
await waitFor(() =>
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
);
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(0);
expect(fetchMock.calls(getExtraTableMetadataEndpoint)).toHaveLength(1);
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import Collapse from 'src/components/Collapse';
import Card from 'src/components/Card';
@@ -24,16 +24,26 @@ import ButtonGroup from 'src/components/ButtonGroup';
import { css, t, styled } from '@superset-ui/core';
import { debounce } from 'lodash';
import { removeDataPreview, removeTables } from 'src/SqlLab/actions/sqlLab';
import {
removeDataPreview,
removeTables,
addDangerToast,
syncTable,
} from 'src/SqlLab/actions/sqlLab';
import {
useTableExtendedMetadataQuery,
useTableMetadataQuery,
} from 'src/hooks/apiResources';
import { Tooltip } from 'src/components/Tooltip';
import CopyToClipboard from 'src/components/CopyToClipboard';
import { IconTooltip } from 'src/components/IconTooltip';
import ModalTrigger from 'src/components/ModalTrigger';
import Loading from 'src/components/Loading';
import useEffectEvent from 'src/hooks/useEffectEvent';
import ColumnElement, { ColumnKeyTypeType } from '../ColumnElement';
import ShowSQL from '../ShowSQL';
interface Column {
export interface Column {
name: string;
keys?: { type: ColumnKeyTypeType }[];
type: string;
@@ -41,18 +51,12 @@ interface Column {
export interface Table {
id: string;
dbId: number;
schema: string;
name: string;
partitions?: {
partitionQuery: string;
latest: object[];
};
metadata?: Record<string, string>;
indexes?: object[];
selectStar?: string;
view?: string;
isMetadataLoading: boolean;
isExtraMetadataLoading: boolean;
columns: Column[];
dataPreviewQueryId?: string | null;
expanded?: boolean;
initialized?: boolean;
}
export interface TableElementProps {
@@ -106,7 +110,61 @@ const StyledCollapsePanel = styled(Collapse.Panel)`
`;
const TableElement = ({ table, ...props }: TableElementProps) => {
const { dbId, schema, name, expanded } = table;
const dispatch = useDispatch();
const {
data: tableMetadata,
isSuccess: isMetadataSuccess,
isLoading: isMetadataLoading,
isError: hasMetadataError,
} = useTableMetadataQuery(
{
dbId,
schema,
table: name,
},
{ skip: !expanded },
);
const {
data: tableExtendedMetadata,
isSuccess: isExtraMetadataSuccess,
isLoading: isExtraMetadataLoading,
isError: hasExtendedMetadataError,
} = useTableExtendedMetadataQuery(
{
dbId,
schema,
table: name,
},
{ skip: !expanded },
);
useEffect(() => {
if (hasMetadataError || hasExtendedMetadataError) {
dispatch(
addDangerToast(t('An error occurred while fetching table metadata')),
);
}
}, [hasMetadataError, hasExtendedMetadataError, dispatch]);
const tableData = {
...tableMetadata,
...tableExtendedMetadata,
};
// TODO: migrate syncTable logic by SIP-93
const syncTableMetadata = useEffectEvent(() => {
const { initialized } = table;
if (!initialized) {
dispatch(syncTable(table, tableData));
}
});
useEffect(() => {
if (isMetadataSuccess && isExtraMetadataSuccess) {
syncTableMetadata();
}
}, [isMetadataSuccess, isExtraMetadataSuccess, syncTableMetadata]);
const [sortColumns, setSortColumns] = useState(false);
const [hovered, setHovered] = useState(false);
@@ -128,11 +186,11 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
const renderWell = () => {
let partitions;
let metadata;
if (table.partitions) {
if (tableData.partitions) {
let partitionQuery;
let partitionClipBoard;
if (table.partitions.partitionQuery) {
({ partitionQuery } = table.partitions);
if (tableData.partitions.partitionQuery) {
({ partitionQuery } = tableData.partitions);
const tt = t('Copy partition query to clipboard');
partitionClipBoard = (
<CopyToClipboard
@@ -143,7 +201,7 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
/>
);
}
const latest = Object.entries(table.partitions?.latest || [])
const latest = Object.entries(tableData.partitions?.latest || [])
.map(([key, value]) => `${key}=${value}`)
.join('/');
@@ -157,8 +215,8 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
);
}
if (table.metadata) {
metadata = Object.entries(table.metadata).map(([key, value]) => (
if (tableData.metadata) {
metadata = Object.entries(tableData.metadata).map(([key, value]) => (
<div>
<small>
<strong>{key}:</strong> {value}
@@ -183,17 +241,17 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
const renderControls = () => {
let keyLink;
const KEYS_FOR_TABLE_TEXT = t('Keys for table');
if (table?.indexes?.length) {
if (tableData?.indexes?.length) {
keyLink = (
<ModalTrigger
modalTitle={`${KEYS_FOR_TABLE_TEXT} ${table.name}`}
modalBody={table.indexes.map((ix, i) => (
modalTitle={`${KEYS_FOR_TABLE_TEXT} ${name}`}
modalBody={tableData.indexes.map((ix, i) => (
<pre key={i}>{JSON.stringify(ix, null, ' ')}</pre>
))}
triggerNode={
<IconTooltip
className="fa fa-key pull-left m-l-2"
tooltip={t('View keys & indexes (%s)', table.indexes.length)}
tooltip={t('View keys & indexes (%s)', tableData.indexes.length)}
/>
}
/>
@@ -214,7 +272,7 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
: t('Sort columns alphabetically')
}
/>
{table.selectStar && (
{tableData.selectStar && (
<CopyToClipboard
copyNode={
<IconTooltip
@@ -224,13 +282,13 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
<i aria-hidden className="fa fa-clipboard pull-left m-l-2" />
</IconTooltip>
}
text={table.selectStar}
text={tableData.selectStar}
shouldShowText={false}
/>
)}
{table.view && (
{tableData.view && (
<ShowSQL
sql={table.view}
sql={tableData.view}
tooltipText={t('Show CREATE VIEW statement')}
title={t('CREATE VIEW statement')}
/>
@@ -253,6 +311,7 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
return (
<div
data-test="table-element-header-container"
className="clearfix header-container"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
@@ -260,7 +319,7 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
<Tooltip
id="copy-to-clipboard-tooltip"
style={{ cursor: 'pointer' }}
title={table.name}
title={name}
trigger={trigger}
>
<StyledSpan
@@ -268,12 +327,12 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
ref={tableNameRef}
className="table-name"
>
<strong>{table.name}</strong>
<strong>{name}</strong>
</StyledSpan>
</Tooltip>
<div className="pull-right header-right-side">
{table.isMetadataLoading || table.isExtraMetadataLoading ? (
{isMetadataLoading || isExtraMetadataLoading ? (
<Loading position="inline" />
) : (
<Fade
@@ -291,8 +350,8 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
const renderBody = () => {
let cols;
if (table.columns) {
cols = table.columns.slice();
if (tableData.columns) {
cols = tableData.columns.slice();
if (sortColumns) {
cols.sort((a: Column, b: Column) => {
const colA = a.name.toUpperCase();