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

This commit is contained in:
JUST.in DO IT
2025-01-31 10:19:37 -08:00
committed by GitHub
parent 101d3fa78d
commit c590e90c87
22 changed files with 889 additions and 145 deletions

View File

@@ -0,0 +1,173 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { type ReactChild } from 'react';
import fetchMock from 'fetch-mock';
import { table, initialState } from 'src/SqlLab/fixtures';
import {
render,
waitFor,
fireEvent,
screen,
} from 'spec/helpers/testing-library';
import TablePreview from '.';
jest.mock(
'src/components/FilterableTable',
() =>
({ data }: { data: Record<string, any>[] }) => (
<div>
{data.map((record, i) => (
<div key={i} data-test="mock-record-row">
{JSON.stringify(record)}
</div>
))}
</div>
),
);
jest.mock(
'react-virtualized-auto-sizer',
() =>
({ children }: { children: (params: { height: number }) => ReactChild }) =>
children({ height: 500 }),
);
jest.mock('src/components/IconTooltip', () => ({
IconTooltip: ({
onClick,
tooltip,
}: {
onClick: () => void;
tooltip: string;
}) => (
<button type="button" data-test="mock-icon-tooltip" onClick={onClick}>
{tooltip}
</button>
),
}));
const getTableMetadataEndpoint =
/\/api\/v1\/database\/\d+\/table_metadata\/(?:\?.*)?$/;
const getExtraTableMetadataEndpoint =
/\/api\/v1\/database\/\d+\/table_metadata\/extra\/(?:\?.*)?$/;
const fetchPreviewEndpoint = 'glob:*/api/v1/sqllab/execute/';
beforeEach(() => {
fetchMock.get(getTableMetadataEndpoint, table);
fetchMock.get(getExtraTableMetadataEndpoint, {});
fetchMock.post(fetchPreviewEndpoint, `{ "data": 123 }`);
});
afterEach(() => {
fetchMock.reset();
});
const mockedProps = {
dbId: table.dbId,
catalog: table.catalog,
schema: table.schema,
tableName: table.name,
};
test('renders columns', async () => {
const { getAllByTestId, queryByText } = render(
<TablePreview {...mockedProps} />,
{
useRedux: true,
initialState,
},
);
await waitFor(() =>
expect(getAllByTestId('mock-record-row')).toHaveLength(
table.columns.length,
),
);
expect(queryByText(`Columns (${table.columns.length})`)).toBeInTheDocument();
});
test('renders indexes', async () => {
const { queryByText } = render(<TablePreview {...mockedProps} />, {
useRedux: true,
initialState,
});
await waitFor(() =>
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
);
expect(queryByText(`Indexes (${table.indexes.length})`)).toBeInTheDocument();
});
test('renders preview', async () => {
const { getByText } = render(<TablePreview {...mockedProps} />, {
useRedux: true,
initialState: {
...initialState,
sqlLab: {
...initialState.sqlLab,
databases: {
[table.dbId]: {
id: table.dbId,
database_name: 'mysql',
disable_data_preview: false,
},
},
},
},
});
await waitFor(() =>
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
);
expect(fetchMock.calls(fetchPreviewEndpoint)).toHaveLength(0);
fireEvent.click(getByText('Data preview'));
await waitFor(() =>
expect(fetchMock.calls(fetchPreviewEndpoint)).toHaveLength(1),
);
});
describe('table actions', () => {
test('refreshes table metadata when triggered', async () => {
const { getByRole, getByText } = render(<TablePreview {...mockedProps} />, {
useRedux: true,
initialState,
});
await waitFor(() =>
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
);
const menuButton = getByRole('button', { name: /Table actions/i });
fireEvent.click(menuButton);
fireEvent.click(getByText('Refresh table schema'));
await waitFor(() =>
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(2),
);
});
test('shows CREATE VIEW statement', async () => {
const { getByRole, getByText } = render(<TablePreview {...mockedProps} />, {
useRedux: true,
initialState,
});
await waitFor(() =>
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
);
const menuButton = getByRole('button', { name: /Table actions/i });
fireEvent.click(menuButton);
fireEvent.click(getByText('Show CREATE VIEW statement'));
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'CREATE VIEW statement' }),
).toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,430 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { type FC, useCallback, useMemo, useRef, useState } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { nanoid } from 'nanoid';
import {
ClientErrorObject,
css,
getExtensionsRegistry,
SafeMarkdown,
styled,
t,
} from '@superset-ui/core';
import AutoSizer from 'react-virtualized-auto-sizer';
import Icons from 'src/components/Icons';
import type { SqlLabRootState } from 'src/SqlLab/types';
import {
Skeleton,
AntdBreadcrumb as Breadcrumb,
AntdDropdown,
} from 'src/components';
import FilterableTable from 'src/components/FilterableTable';
import Tabs from 'src/components/Tabs';
import {
tableApiUtil,
TableMetaData,
useTableExtendedMetadataQuery,
useTableMetadataQuery,
} from 'src/hooks/apiResources';
import { runTablePreviewQuery } from 'src/SqlLab/actions/sqlLab';
import Alert from 'src/components/Alert';
import { Menu } from 'src/components/Menu';
import Card from 'src/components/Card';
import CopyToClipboard from 'src/components/CopyToClipboard';
import ResultSet from '../ResultSet';
import ShowSQL from '../ShowSQL';
type Props = {
dbId: number | string;
schema?: string;
catalog?: string | null;
tableName: string;
};
const extensionsRegistry = getExtensionsRegistry();
const COLUMN_KEYS = ['column_name', 'column_type', 'keys', 'comment'];
const MENUS = [
{
key: 'refresh-table',
label: t('Refresh table schema'),
icon: <i aria-hidden className="fa fa-refresh" />,
},
{
key: 'copy-select-statement',
label: t('Copy SELECT statement'),
icon: <i aria-hidden className="fa fa-clipboard m-l-2" />,
},
{
key: 'show-create-view-statement',
label: t('Show CREATE VIEW statement'),
icon: <i aria-hidden className="fa fa-eye" />,
},
];
const TAB_HEADER_HEIGHT = 80;
const PREVIEW_TOP_ACTION_HEIGHT = 30;
const PREVIEW_QUERY_LIMIT = 100;
const Title = styled.div`
display: flex;
flex-direction: row;
align-items: center;
column-gap: ${({ theme }) => theme.gridUnit}px;
font-size: ${({ theme }) => theme.typography.sizes.l}px;
font-weight: ${({ theme }) => theme.typography.weights.bold};
`;
const renderWell = (partitions: TableMetaData['partitions']) => {
if (!partitions) {
return null;
}
const { partitionQuery } = partitions;
let partitionClipBoard;
if (partitionQuery) {
const tt = t('Copy partition query to clipboard');
partitionClipBoard = (
<CopyToClipboard
text={partitionQuery}
shouldShowText={false}
tooltipText={tt}
copyNode={<i className="fa fa-clipboard" />}
/>
);
}
const latest = Object.entries(partitions.latest || [])
.map(([key, value]) => `${key}=${value}`)
.join('/');
return (
<Card size="small">
<div>
<small>
{t('latest partition:')} {latest}
</small>{' '}
{partitionClipBoard}
</div>
</Card>
);
};
const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
const dispatch = useDispatch();
const [databaseName, backend, disableDataPreview] = useSelector<
SqlLabRootState,
string[]
>(
({ sqlLab: { databases } }) => [
databases[dbId]?.database_name,
databases[dbId]?.backend,
databases[dbId]?.disable_data_preview,
],
shallowEqual,
);
const copyStatementActionRef = useRef<HTMLButtonElement | null>(null);
const showViewStatementActionRef = useRef<HTMLButtonElement | null>(null);
const [previewQueryId, setPreviewQueryId] = useState<string>();
const {
currentData: tableMetadata,
isLoading: isMetadataLoading,
isFetching: isMetadataRefreshing,
isError: hasMetadataError,
error: metadataError,
} = useTableMetadataQuery(
{
dbId,
catalog,
schema: schema ?? '',
table: tableName ?? '',
},
{ skip: !dbId || !schema || !tableName },
);
const { currentData: tableExtendedMetadata, error: metadataExtrError } =
useTableExtendedMetadataQuery(
{
dbId,
catalog,
schema: schema ?? '',
table: tableName ?? '',
},
{ skip: !dbId || !schema || !tableName },
);
const data = useMemo(
() =>
(tableMetadata?.columns.length ?? 0) > 0
? tableMetadata?.columns.map(
({ name, type, longType, keys, comment }) => ({
column_name: name,
column_type: longType || type,
keys,
comment,
}),
)
: undefined,
[tableMetadata],
);
const hasKeys = useMemo(
() => data?.some(({ keys }) => Boolean(keys?.length)),
[data],
);
const columns = useMemo(
() => (hasKeys ? COLUMN_KEYS : COLUMN_KEYS.filter(name => name !== 'keys')),
[hasKeys],
);
const tableData = {
dataPreviewQueryId: previewQueryId,
...tableMetadata,
...tableExtendedMetadata,
};
const refreshTableMetadata = () => {
dispatch(
tableApiUtil.invalidateTags([{ type: 'TableMetadatas', id: tableName }]),
);
};
const ResultTable =
extensionsRegistry.get('sqleditor.extension.resultTable') ??
FilterableTable;
const customTabs =
extensionsRegistry.get('sqleditor.extension.tablePreview') ?? [];
const onTabSwitch = useCallback(
(activeKey: string) => {
if (activeKey === 'sample' && !previewQueryId) {
const queryId = nanoid(11);
dispatch(
runTablePreviewQuery(
{
previewQueryId: queryId,
dbId,
catalog,
schema,
name: tableName,
selectStar: tableData.selectStar,
},
true,
),
);
setPreviewQueryId(queryId);
}
},
[
previewQueryId,
dbId,
catalog,
schema,
tableName,
tableData.selectStar,
dispatch,
],
);
const dropdownMenu = useMemo(() => {
let menus = [...MENUS];
if (!tableData.selectStar) {
menus = menus.filter(({ key }) => key !== 'copy-select-statement');
}
if (!tableData.view) {
menus = menus.filter(({ key }) => key !== 'show-create-view-statement');
}
return menus;
}, [tableData.view, tableData.selectStar]);
if (isMetadataLoading) {
return <Skeleton active />;
}
if (hasMetadataError || metadataExtrError) {
return (
<Alert
type="warning"
message={
((metadataError || metadataExtrError) as ClientErrorObject)?.error
}
/>
);
}
if (!data) {
return (
<Alert
type="warning"
message={t('Cannot find the table (%s) metadata.', tableName)}
closable={false}
/>
);
}
return (
<div
css={css`
height: 100%;
display: flex;
flex-direction: column;
`}
>
<Breadcrumb separator=">">
<Breadcrumb.Item>{backend}</Breadcrumb.Item>
<Breadcrumb.Item>{databaseName}</Breadcrumb.Item>
{catalog && <Breadcrumb.Item>{catalog}</Breadcrumb.Item>}
{schema && <Breadcrumb.Item>{schema}</Breadcrumb.Item>}
<Breadcrumb.Item> </Breadcrumb.Item>
</Breadcrumb>
<div style={{ display: 'none' }}>
<CopyToClipboard
copyNode={
<button type="button" ref={copyStatementActionRef}>
invisible button
</button>
}
text={tableData.selectStar}
shouldShowText={false}
/>
{tableData.view && (
<ShowSQL
sql={tableData.view}
tooltipText={t('Show CREATE VIEW statement')}
title={t('CREATE VIEW statement')}
triggerNode={
<button type="button" ref={showViewStatementActionRef}>
invisible button
</button>
}
/>
)}
</div>
<Title>
<Icons.Table iconSize="l" />
{tableName}
<AntdDropdown
overlay={
<Menu
onClick={({ key }) => {
if (key === 'refresh-table') {
refreshTableMetadata();
}
if (key === 'copy-select-statement') {
copyStatementActionRef.current?.click();
}
if (key === 'show-create-view-statement') {
showViewStatementActionRef.current?.click();
}
}}
items={dropdownMenu}
/>
}
trigger={['click']}
>
<Icons.DownSquareOutlined
iconSize="m"
style={{ marginTop: 2, marginLeft: 4 }}
aria-label={t('Table actions')}
/>
</AntdDropdown>
</Title>
{isMetadataRefreshing ? (
<Skeleton active />
) : (
<>
{tableData.comment && <SafeMarkdown source={tableData.comment} />}
{renderWell(tableData.partitions)}
<div
css={css`
flex: 1 1 auto;
`}
>
<AutoSizer disableWidth>
{({ height }) => (
<Tabs
fullWidth={false}
onTabClick={onTabSwitch}
css={css`
height: ${height}px;
`}
>
<Tabs.TabPane
tab={t('Columns (%s)', data.length)}
key="columns"
>
<ResultTable
queryId="table-columns"
height={height - TAB_HEADER_HEIGHT}
data={data}
orderedColumnKeys={columns}
/>
</Tabs.TabPane>
{tableData?.selectStar && !disableDataPreview && (
<Tabs.TabPane tab={t('Data preview')} key="sample">
{previewQueryId && (
<ResultSet
queryId={previewQueryId}
visualize={false}
csv={false}
cache
height={
height -
TAB_HEADER_HEIGHT -
PREVIEW_TOP_ACTION_HEIGHT
}
displayLimit={PREVIEW_QUERY_LIMIT}
defaultQueryLimit={PREVIEW_QUERY_LIMIT}
/>
)}
</Tabs.TabPane>
)}
{tableData?.indexes && tableData.indexes.length > 0 && (
<Tabs.TabPane
tab={t('Indexes (%s)', tableData.indexes.length)}
key="indexes"
>
{tableData.indexes.map((ix, i) => (
<pre className="code" key={i}>
{JSON.stringify(ix, null, ' ')}
</pre>
))}
</Tabs.TabPane>
)}
{tableData?.metadata && (
<Tabs.TabPane tab={t('Metadata')} key="metadata">
<ResultTable
queryId="table-metadata"
height={height - TAB_HEADER_HEIGHT}
data={Object.entries(tableData.metadata).map(
([name, value]) => ({ name, value }),
)}
orderedColumnKeys={['name', 'value']}
/>
</Tabs.TabPane>
)}
{customTabs.map(([title, ExtComponent]) => (
<Tabs.TabPane tab={title} key={title}>
<ExtComponent
dbId={Number(dbId)}
schema={schema ?? ''}
tableName={tableName}
/>
</Tabs.TabPane>
))}
</Tabs>
)}
</AutoSizer>
</div>
</>
)}
</div>
);
};
export default TablePreview;