fix(SqlLab): South pane visual changes (#35601)

(cherry picked from commit 6e60a00d69)
This commit is contained in:
Mehmet Salih Yavuz
2025-10-27 22:07:06 +03:00
committed by Joe Li
parent 78983a6f25
commit 5ecd067ed3
10 changed files with 266 additions and 107 deletions

View File

@@ -0,0 +1,108 @@
/**
* 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 { render, screen, userEvent } from '@superset-ui/core/spec';
import { Icons } from '@superset-ui/core/components/Icons';
import { ActionButton } from '.';
const defaultProps = {
label: 'test-action',
icon: <Icons.EditOutlined />,
onClick: jest.fn(),
};
test('renders action button with icon', () => {
render(<ActionButton {...defaultProps} />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('data-test', 'test-action');
expect(button).toHaveClass('action-button');
});
test('calls onClick when clicked', async () => {
const onClick = jest.fn();
render(<ActionButton {...defaultProps} onClick={onClick} />);
const button = screen.getByRole('button');
userEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
test('renders with tooltip when tooltip prop is provided', async () => {
const tooltipText = 'This is a tooltip';
render(<ActionButton {...defaultProps} tooltip={tooltipText} />);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(tooltip).toHaveTextContent(tooltipText);
});
test('renders without tooltip when tooltip prop is not provided', async () => {
render(<ActionButton {...defaultProps} />);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = screen.queryByRole('tooltip');
expect(tooltip).not.toBeInTheDocument();
});
test('supports ReactElement tooltip', async () => {
const tooltipElement = <div>Custom tooltip content</div>;
render(<ActionButton {...defaultProps} tooltip={tooltipElement} />);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(tooltip).toHaveTextContent('Custom tooltip content');
});
test('renders different icons correctly', () => {
render(<ActionButton {...defaultProps} icon={<Icons.DeleteOutlined />} />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
test('renders with custom placement for tooltip', async () => {
const tooltipText = 'Tooltip with custom placement';
render(
<ActionButton {...defaultProps} tooltip={tooltipText} placement="bottom" />,
);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
});
test('has proper accessibility attributes', () => {
render(<ActionButton {...defaultProps} />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('tabIndex', '0');
expect(button).toHaveAttribute('role', 'button');
});

View File

@@ -0,0 +1,75 @@
/**
* 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 { ReactElement } from 'react';
import {
Tooltip,
type TooltipPlacement,
type IconType,
} from '@superset-ui/core/components';
import { css, useTheme } from '@superset-ui/core';
export interface ActionProps {
label: string;
tooltip?: string | ReactElement;
placement?: TooltipPlacement;
icon: IconType;
onClick: () => void;
}
export const ActionButton = ({
label,
tooltip,
placement,
icon,
onClick,
}: ActionProps) => {
const theme = useTheme();
const actionButton = (
<span
role="button"
tabIndex={0}
css={css`
cursor: pointer;
color: ${theme.colorIcon};
margin-right: ${theme.sizeUnit}px;
&:hover {
path {
fill: ${theme.colorPrimary};
}
}
`}
className="action-button"
data-test={label}
onClick={onClick}
>
{icon}
</span>
);
const tooltipId = `${label.replaceAll(' ', '-').toLowerCase()}-tooltip`;
return tooltip ? (
<Tooltip id={tooltipId} title={tooltip} placement={placement}>
{actionButton}
</Tooltip>
) : (
actionButton
);
};

View File

@@ -21,15 +21,18 @@ import { css, styled, useTheme } from '@superset-ui/core';
// eslint-disable-next-line no-restricted-imports
import { Tabs as AntdTabs, TabsProps as AntdTabsProps } from 'antd';
import { Icons } from '@superset-ui/core/components/Icons';
import type { SerializedStyles } from '@emotion/react';
export interface TabsProps extends AntdTabsProps {
allowOverflow?: boolean;
contentStyle?: SerializedStyles;
}
const StyledTabs = ({
animated = false,
allowOverflow = true,
tabBarStyle,
contentStyle,
...props
}: TabsProps) => {
const theme = useTheme();
@@ -46,6 +49,7 @@ const StyledTabs = ({
.ant-tabs-content-holder {
overflow: ${allowOverflow ? 'visible' : 'auto'};
${contentStyle}
}
.ant-tabs-tab {
flex: 1 1 auto;
@@ -85,9 +89,10 @@ const Tabs = Object.assign(StyledTabs, {
});
const StyledEditableTabs = styled(StyledTabs)`
${({ theme }) => `
${({ theme, contentStyle }) => `
.ant-tabs-content-holder {
background: ${theme.colorBgContainer};
${contentStyle}
}
& > .ant-tabs-nav {

View File

@@ -181,3 +181,4 @@ export {
setupAGGridModules,
defaultModules,
} from './ThemedAgGridReact';
export { ActionButton, type ActionProps } from './ActionButton';

View File

@@ -27,6 +27,7 @@ import {
css,
FeatureFlag,
isFeatureEnabled,
useTheme,
} from '@superset-ui/core';
import QueryTable from 'src/SqlLab/components/QueryTable';
import { SqlLabRootState } from 'src/SqlLab/types';
@@ -67,6 +68,7 @@ const QueryHistory = ({
const { id, tabViewId } = useQueryEditor(String(queryEditorId), [
'tabViewId',
]);
const theme = useTheme();
const editorId = tabViewId ?? id;
const [ref, hasReachedBottom] = useInView({ threshold: 0 });
const [pageIndex, setPageIndex] = useState(0);
@@ -118,7 +120,11 @@ const QueryHistory = ({
}
return editorQueries.length > 0 ? (
<>
<div
css={css`
padding-left: ${theme.sizeUnit * 4}px;
`}
>
<QueryTable
columns={[
'state',
@@ -144,7 +150,7 @@ const QueryHistory = ({
/>
)}
{isFetching && <Skeleton active />}
</>
</div>
) : (
<StyledEmptyStateWrapper>
<EmptyState

View File

@@ -149,6 +149,7 @@ const ReturnedRows = styled.div`
const ResultSetControls = styled.div`
display: flex;
justify-content: space-between;
padding-left: ${({ theme }) => theme.sizeUnit * 4}px;
`;
const ResultSetButtons = styled.div`
@@ -663,6 +664,7 @@ const ResultSet = ({
css={css`
display: flex;
justify-content: space-between;
padding-left: ${theme.sizeUnit * 4}px;
align-items: center;
gap: ${GAP}px;
`}
@@ -698,6 +700,7 @@ const ResultSet = ({
<div
css={css`
flex: 1 1 auto;
padding-left: ${theme.sizeUnit * 4}px;
`}
>
<AutoSizer disableWidth>

View File

@@ -191,6 +191,7 @@ const StyledSqlEditor = styled.div`
.queryPane {
flex: 1 1 auto;
padding: ${theme.sizeUnit * 2}px;
padding-left: 0px;
overflow-y: auto;
overflow-x: scroll;
}

View File

@@ -137,32 +137,30 @@ test('renders preview', async () => {
describe('table actions', () => {
test('refreshes table metadata when triggered', async () => {
const { getByRole, getByText } = render(<TablePreview {...mockedProps} />, {
const { getByRole } = 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'));
const refreshButton = getByRole('button', { name: 'sync' });
fireEvent.click(refreshButton);
await waitFor(() =>
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(2),
);
});
test('shows CREATE VIEW statement', async () => {
const { getByRole, getByText } = render(<TablePreview {...mockedProps} />, {
const { getByRole } = 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'));
const viewButton = getByRole('button', { name: 'eye' });
fireEvent.click(viewButton);
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'CREATE VIEW statement' }),

View File

@@ -25,15 +25,15 @@ import {
getExtensionsRegistry,
styled,
t,
useTheme,
} from '@superset-ui/core';
import {
SafeMarkdown,
Alert,
Breadcrumb,
Button,
Card,
Dropdown,
Skeleton,
Flex,
} from '@superset-ui/core/components';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Icons } from '@superset-ui/core/components/Icons';
@@ -47,7 +47,7 @@ import {
useTableMetadataQuery,
} from 'src/hooks/apiResources';
import { runTablePreviewQuery } from 'src/SqlLab/actions/sqlLab';
import { Menu } from '@superset-ui/core/components/Menu';
import { ActionButton } from '@superset-ui/core/components/ActionButton';
import ResultSet from '../ResultSet';
import ShowSQL from '../ShowSQL';
@@ -68,23 +68,6 @@ const TABS_KEYS = {
INDEXES: 'indexes',
SAMPLE: 'sample',
};
const MENUS = [
{
key: 'refresh-table',
label: t('Refresh table schema'),
icon: <Icons.SyncOutlined iconSize="s" aria-hidden />,
},
{
key: 'copy-select-statement',
label: t('Copy SELECT statement'),
icon: <Icons.CopyOutlined iconSize="s" aria-hidden />,
},
{
key: 'show-create-view-statement',
label: t('Show CREATE VIEW statement'),
icon: <Icons.EyeOutlined iconSize="s" aria-hidden />,
},
];
const TAB_HEADER_HEIGHT = 80;
const PREVIEW_TOP_ACTION_HEIGHT = 30;
const PREVIEW_QUERY_LIMIT = 100;
@@ -97,6 +80,8 @@ const Title = styled.div`
column-gap: ${theme.sizeUnit}px;
font-size: ${theme.fontSizeLG}px;
font-weight: ${theme.fontWeightStrong};
padding-top: ${theme.sizeUnit * 2}px;
padding-left: ${theme.sizeUnit * 4}px;
`}
`;
const renderWell = (partitions: TableMetaData['partitions']) => {
@@ -134,6 +119,7 @@ const renderWell = (partitions: TableMetaData['partitions']) => {
const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
const dispatch = useDispatch();
const theme = useTheme();
const [databaseName, backend, disableDataPreview] = useSelector<
SqlLabRootState,
string[]
@@ -241,16 +227,37 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
],
);
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]);
const titleActions = () => (
<Flex
align="center"
css={css`
padding-left: ${theme.sizeUnit * 2}px;
`}
>
<ActionButton
label={t('Refresh table schema')}
tooltip={t('Refresh table schema')}
icon={<Icons.SyncOutlined iconSize="m" />}
onClick={refreshTableMetadata}
/>
{tableData.selectStar && (
<ActionButton
label={t('Copy SELECT statement')}
icon={<Icons.CopyOutlined iconSize="m" />}
tooltip={t('Copy SELECT statement')}
onClick={() => copyStatementActionRef.current?.click()}
/>
)}
{tableData.view && (
<ActionButton
label={t('Show CREATE VIEW statement')}
icon={<Icons.EyeOutlined iconSize="m" />}
tooltip={t('Show CREATE VIEW statement')}
onClick={() => showViewStatementActionRef.current?.click()}
/>
)}
</Flex>
);
if (isMetadataLoading) {
return <Skeleton active />;
@@ -283,7 +290,12 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
flex-direction: column;
`}
>
<Breadcrumb separator=">">
<Breadcrumb
separator=">"
css={css`
padding-left: ${theme.sizeUnit * 4}px;
`}
>
<Breadcrumb.Item>{backend}</Breadcrumb.Item>
<Breadcrumb.Item>{databaseName}</Breadcrumb.Item>
{catalog && <Breadcrumb.Item>{catalog}</Breadcrumb.Item>}
@@ -316,33 +328,7 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
<Title>
<Icons.InsertRowAboveOutlined iconSize="l" />
{tableName}
<Dropdown
popupRender={() => (
<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']}
>
<Button buttonSize="xsmall" buttonStyle="link">
<Icons.DownSquareOutlined
iconSize="m"
style={{ marginTop: 2, marginLeft: 4 }}
aria-label={t('Table actions')}
/>
</Button>
</Dropdown>
{titleActions()}
</Title>
{isMetadataRefreshing ? (
<Skeleton active />
@@ -444,7 +430,11 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
css={css`
height: ${height}px;
`}
tabBarStyle={{ paddingLeft: theme.sizeUnit * 4 }}
items={tabItems}
contentStyle={css`
padding-left: ${theme.sizeUnit * 4}px;
`}
/>
);
}}

View File

@@ -38,10 +38,10 @@
import { ReactElement } from 'react';
import { styled } from '@superset-ui/core';
import {
Icons,
IconNameType,
Tooltip,
Icons,
type TooltipPlacement,
ActionButton,
} from '@superset-ui/core/components';
export type ActionProps = {
@@ -59,47 +59,19 @@ interface ActionsBarProps {
const StyledActions = styled.span`
white-space: nowrap;
min-width: 100px;
.action-button {
cursor: pointer;
color: ${({ theme }) => theme.colorIcon};
margin-right: ${({ theme }) => theme.sizeUnit}px;
&:hover {
path {
fill: ${({ theme }) => theme.colorPrimary};
}
}
}
`;
export function ActionsBar({ actions }: ActionsBarProps) {
return (
<StyledActions className="actions">
{actions.map((action, index) => {
const ActionIcon = Icons[action.icon as IconNameType];
const actionButton = (
<span
role="button"
tabIndex={0}
style={{ cursor: 'pointer' }}
className="action-button"
data-test={action.label}
onClick={action.onClick}
key={action.tooltip ? undefined : index}
>
<ActionIcon iconSize="l" />
</span>
);
return action.tooltip ? (
<Tooltip
id={`${action.label}-tooltip`}
title={action.tooltip}
placement={action.placement}
key={index}
>
{actionButton}
</Tooltip>
) : (
actionButton
{actions.map(({ icon, tooltip, ...rest }, index) => {
const IconComponent = Icons[icon as IconNameType];
return (
<ActionButton
key={tooltip ? undefined : index}
icon={<IconComponent />}
{...rest}
/>
);
})}
</StyledActions>