diff --git a/superset-frontend/packages/superset-ui-core/src/components/ActionButton/ActionButton.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/ActionButton/ActionButton.test.tsx new file mode 100644 index 000000000000..123d53495056 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/ActionButton/ActionButton.test.tsx @@ -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: , + onClick: jest.fn(), +}; + +test('renders action button with icon', () => { + render(); + + 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(); + + 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(); + + 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(); + + const button = screen.getByRole('button'); + userEvent.hover(button); + + const tooltip = screen.queryByRole('tooltip'); + expect(tooltip).not.toBeInTheDocument(); +}); + +test('supports ReactElement tooltip', async () => { + const tooltipElement =
Custom tooltip content
; + render(); + + 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(} />); + + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); +}); + +test('renders with custom placement for tooltip', async () => { + const tooltipText = 'Tooltip with custom placement'; + render( + , + ); + + const button = screen.getByRole('button'); + userEvent.hover(button); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); +}); + +test('has proper accessibility attributes', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('tabIndex', '0'); + expect(button).toHaveAttribute('role', 'button'); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/ActionButton/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/ActionButton/index.tsx new file mode 100644 index 000000000000..38ccdda399fc --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/ActionButton/index.tsx @@ -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 = ( + + {icon} + + ); + + const tooltipId = `${label.replaceAll(' ', '-').toLowerCase()}-tooltip`; + + return tooltip ? ( + + {actionButton} + + ) : ( + actionButton + ); +}; diff --git a/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx b/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx index d834cd05f22d..edc50623f758 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx @@ -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 { diff --git a/superset-frontend/packages/superset-ui-core/src/components/index.ts b/superset-frontend/packages/superset-ui-core/src/components/index.ts index 6e0cf8f6adcd..55829c0b1c6e 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts @@ -189,3 +189,4 @@ export { type CodeEditorMode, type CodeEditorTheme, } from './CodeEditor'; +export { ActionButton, type ActionProps } from './ActionButton'; diff --git a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx index 0a311d48084b..0ae67caa3657 100644 --- a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx @@ -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 ? ( - <> +
)} {isFetching && } - +
) : ( theme.sizeUnit * 4}px; `; const ResultSetButtons = styled.div` @@ -669,6 +670,7 @@ const ResultSet = ({ css={css` display: flex; justify-content: space-between; + padding-left: ${theme.sizeUnit * 4}px; align-items: center; gap: ${GAP}px; `} @@ -704,6 +706,7 @@ const ResultSet = ({
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index 547f2850e4f7..4585ed09cdfe 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -185,6 +185,7 @@ const StyledSqlEditor = styled.div` .queryPane { padding: ${theme.sizeUnit * 2}px; + padding-left: 0px; + .ant-splitter-bar .ant-splitter-bar-dragger { &::before { background: transparent; diff --git a/superset-frontend/src/SqlLab/components/TablePreview/TablePreview.test.tsx b/superset-frontend/src/SqlLab/components/TablePreview/TablePreview.test.tsx index dc2ec01258b1..dadf62888b8a 100644 --- a/superset-frontend/src/SqlLab/components/TablePreview/TablePreview.test.tsx +++ b/superset-frontend/src/SqlLab/components/TablePreview/TablePreview.test.tsx @@ -138,32 +138,30 @@ test('renders preview', async () => { // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('table actions', () => { test('refreshes table metadata when triggered', async () => { - const { getByRole, getByText } = render(, { + const { getByRole } = render(, { 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(, { + const { getByRole } = render(, { 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' }), diff --git a/superset-frontend/src/SqlLab/components/TablePreview/index.tsx b/superset-frontend/src/SqlLab/components/TablePreview/index.tsx index 6973adc19f9c..2cad2baea6a5 100644 --- a/superset-frontend/src/SqlLab/components/TablePreview/index.tsx +++ b/superset-frontend/src/SqlLab/components/TablePreview/index.tsx @@ -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: , - }, - { - key: 'copy-select-statement', - label: t('Copy SELECT statement'), - icon: , - }, - { - key: 'show-create-view-statement', - label: t('Show CREATE VIEW statement'), - icon: , - }, -]; const TAB_HEADER_HEIGHT = 80; const PREVIEW_QUERY_LIMIT = 100; @@ -96,6 +79,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']) => { @@ -133,6 +118,7 @@ const renderWell = (partitions: TableMetaData['partitions']) => { const TablePreview: FC = ({ dbId, catalog, schema, tableName }) => { const dispatch = useDispatch(); + const theme = useTheme(); const [databaseName, backend, disableDataPreview] = useSelector< SqlLabRootState, string[] @@ -240,16 +226,37 @@ const TablePreview: FC = ({ 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 = () => ( + + } + onClick={refreshTableMetadata} + /> + {tableData.selectStar && ( + } + tooltip={t('Copy SELECT statement')} + onClick={() => copyStatementActionRef.current?.click()} + /> + )} + {tableData.view && ( + } + tooltip={t('Show CREATE VIEW statement')} + onClick={() => showViewStatementActionRef.current?.click()} + /> + )} + + ); if (isMetadataLoading) { return ; @@ -282,7 +289,12 @@ const TablePreview: FC = ({ dbId, catalog, schema, tableName }) => { flex-direction: column; `} > - + {backend} {databaseName} {catalog && {catalog}} @@ -315,33 +327,7 @@ const TablePreview: FC = ({ dbId, catalog, schema, tableName }) => { <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()} {isMetadataRefreshing ? ( @@ -440,7 +426,11 @@ const TablePreview: FC = ({ dbId, catalog, schema, tableName }) => { css={css` height: ${height}px; `} + tabBarStyle={{ paddingLeft: theme.sizeUnit * 4 }} items={tabItems} + contentStyle={css` + padding-left: ${theme.sizeUnit * 4}px; + `} /> ); }} diff --git a/superset-frontend/src/components/ListView/ActionsBar.tsx b/superset-frontend/src/components/ListView/ActionsBar.tsx index 7a4f57b7fc66..40959e8f3638 100644 --- a/superset-frontend/src/components/ListView/ActionsBar.tsx +++ b/superset-frontend/src/components/ListView/ActionsBar.tsx @@ -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 ( - {actions.map((action, index) => { - const ActionIcon = Icons[action.icon as IconNameType]; - const actionButton = ( - - - - ); - return action.tooltip ? ( - - {actionButton} - - ) : ( - actionButton + {actions.map(({ icon, tooltip, ...rest }, index) => { + const IconComponent = Icons[icon as IconNameType]; + return ( + } + {...rest} + /> ); })}