diff --git a/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx b/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx index 63283a08625..9a00dda136e 100644 --- a/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx +++ b/superset-frontend/src/SqlLab/components/ExploreResultsButton/ExploreResultsButton.test.tsx @@ -34,18 +34,14 @@ const setup = ( // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('ExploreResultsButton', () => { test('renders', async () => { - const { queryByText } = setup(jest.fn(), { + setup(jest.fn(), { database: { allows_subquery: true }, }); - expect(queryByText('Create chart')).toBeInTheDocument(); - // Updated line to match the actual button name that includes the icon expect(screen.getByRole('button', { name: /Create chart/i })).toBeEnabled(); }); test('renders disabled if subquery not allowed', async () => { - const { queryByText } = setup(jest.fn()); - expect(queryByText('Create chart')).toBeInTheDocument(); - // Updated line to match the actual button name that includes the icon + setup(jest.fn()); expect( screen.getByRole('button', { name: /Create chart/i }), ).toBeDisabled(); diff --git a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx index c4853025106..39deb485b2c 100644 --- a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx @@ -38,16 +38,16 @@ const ExploreResultsButton = ({ return ( } + variant="text" + color="primary" + icon={} onClick={onClick} disabled={!allowsSubquery} role="button" - tooltip={t('Explore the result set in the data exploration view')} + tooltip={t('Create chart')} + aria-label={t('Create chart')} data-test="explore-results-button" - > - {t('Create chart')} - + /> ); }; diff --git a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx index d3e37a3586d..b8c85f06284 100644 --- a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx @@ -29,6 +29,8 @@ import { SqlLabRootState } from 'src/SqlLab/types'; import { useEditorQueriesQuery } from 'src/hooks/apiResources/queries'; import useEffectEvent from 'src/hooks/useEffectEvent'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; +import PanelToolbar from 'src/components/PanelToolbar'; +import { ViewContribution } from 'src/SqlLab/contributions'; interface QueryHistoryProps { queryEditorId: string | number; @@ -119,6 +121,7 @@ const QueryHistory = ({ return editorQueries.length > 0 ? ( <> + 2 * theme.sizeUnit}px; `; -const CopyStyledButton = styled(Button)` - &:hover { - color: ${({ theme }) => theme.colorPrimary}; - text-decoration: unset; - } - - span > :first-of-type { - margin: 0; - } -`; - -const ROWS_CHIP_WIDTH = 100; const GAP = 8; const extensionsRegistry = getExtensionsRegistry(); @@ -389,8 +373,71 @@ const ResultSet = ({ } }; + const defaultPrimaryActions = ( + <> + {visualize && database?.allows_virtual_table_explore && ( + + )} + {csv && canExportData && ( + } + tooltip={t('Download to CSV')} + aria-label={t('Download to CSV')} + {...(!shouldUseStreamingExport() && { + href: getExportCsvUrl(query.id), + })} + data-test="export-csv-button" + onClick={e => { + const useStreaming = shouldUseStreamingExport(); + + if (useStreaming) { + e.preventDefault(); + setShowStreamingModal(true); + + startExport({ + url: makeUrl('/api/v1/sqllab/export_streaming/'), + payload: { client_id: query.id }, + exportType: 'csv', + expectedRows: rows, + }); + } else { + handleDownloadCsv(e); + } + }} + /> + )} + {canExportData && ( + } + tooltip={t('Copy to Clipboard')} + aria-label={t('Copy to Clipboard')} + data-test="copy-to-clipboard-button" + /> + } + hideTooltip + onCopyEnd={() => + logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {}) + } + /> + )} + > + ); + return ( - + setShowSaveDatasetModal(false)} @@ -401,98 +448,21 @@ const ResultSet = ({ )} datasource={datasource} /> - - {visualize && database?.allows_virtual_table_explore && ( - - )} - {csv && canExportData && ( - { - const useStreaming = shouldUseStreamingExport(); - - if (useStreaming) { - e.preventDefault(); - setShowStreamingModal(true); - - startExport({ - url: makeUrl('/api/v1/sqllab/export_streaming/'), - payload: { client_id: query.id }, - exportType: 'csv', - expectedRows: rows, - }); - } else { - handleDownloadCsv(e); - } - }} - > - {t('Download to CSV')} - - )} - {canExportData && ( - - {t('Copy to Clipboard')} - - } - hideTooltip - onCopyEnd={() => - logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {}) - } - /> - )} - - {search && ( - - )} - + + ); } - return ; + return null; }; - const renderRowsReturned = (alertMessage: boolean) => { + const renderRowsReturned = () => { const { results, rows, queryLimit, limitingFactor } = query; let limitMessage = ''; const limitReached = results?.displayLimitReached; const limit = queryLimit || results.query.limit; - const isAdmin = !!user?.roles?.Admin; - const rowsCount = Math.min(rows || 0, results?.data?.length || 0); - - const displayMaxRowsReachedMessage = { - withAdmin: t( - 'The number of results displayed is limited to %(rows)d by the configuration DISPLAY_MAX_ROW. ' + - 'Please add additional limits/filters or download to csv to see more rows up to ' + - 'the %(limit)d limit.', - { rows: rowsCount, limit }, - ), - withoutAdmin: t( - 'The number of results displayed is limited to %(rows)d. ' + - 'Please add additional limits/filters, download to csv, or contact an admin ' + - 'to see more rows up to the %(limit)d limit.', - { rows: rowsCount, limit }, - ), - }; const shouldUseDefaultDropdownAlert = limit === defaultQueryLimit && limitingFactor === LimitingFactor.Dropdown; @@ -514,76 +484,53 @@ const ResultSet = ({ 'The number of rows displayed is limited to %(rows)d by the query and limit dropdown.', { rows }, ); + } else if (shouldUseDefaultDropdownAlert) { + limitMessage = t( + 'The number of rows displayed is limited to %(rows)d by the dropdown.', + { rows }, + ); + } else if (limitReached) { + limitMessage = t( + 'The number of results displayed is limited to %(rows)d.', + { rows }, + ); } + const formattedRowCount = getNumberFormatter()(rows); const rowsReturnedMessage = t('%(rows)d rows returned', { rows, }); - const tooltipText = `${rowsReturnedMessage}. ${limitMessage}`; - - if (alertMessage) { - return ( - <> - {!limitReached && shouldUseDefaultDropdownAlert && ( - - - - )} - {limitReached && ( - - - - )} - > - ); - } - const showRowsReturned = - showSqlInline || (!limitReached && !shouldUseDefaultDropdownAlert); + const hasWarning = !!limitMessage; + const tooltipText = hasWarning + ? `${rowsReturnedMessage}. ${limitMessage}` + : rowsReturnedMessage; return ( - <> - {showRowsReturned && ( - - - + + + {hasWarning && ( + - {limitMessage && ( - - )} - {tn('%s row', '%s rows', rows, formattedRowCount)} - - - - )} - > + /> + )} + {tn('%s row', '%s rows', rows, formattedRowCount)} + + + ); }; @@ -728,45 +675,58 @@ const ResultSet = ({ return ( <> - {renderControls()} - {showSql && showSqlInline ? ( - <> - - + {renderControls()} + + {showSql && ( + <> + {sql} - - {renderRowsReturned(false)} - - {renderRowsReturned(true)} - > - ) : ( - <> - {renderRowsReturned(false)} - {renderRowsReturned(true)} - {sql} - > - )} + + + > + )} + {renderRowsReturned()} + {search && ( + + )} + {useFixedHeight && height !== undefined ? ( ) : ( diff --git a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx index 96e025ac485..6d46c4cd78a 100644 --- a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx +++ b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx @@ -62,6 +62,7 @@ export type QueryPayload = { } & Pick; const Styles = styled.span` + display: contents; span[role='img']:not([aria-label='down']) { display: flex; margin: 0; diff --git a/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx b/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx index 661efb7171d..2f341498151 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/Results.tsx @@ -80,7 +80,7 @@ const Results: FC = ({ ) { return ( ); diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx index 1f4697db6b8..4478e716bdc 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx @@ -29,7 +29,7 @@ import { Flex, Label } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { SqlLabRootState } from 'src/SqlLab/types'; import { ViewContribution } from 'src/SqlLab/contributions'; -import MenuListExtension from 'src/components/MenuListExtension'; +import PanelToolbar from 'src/components/PanelToolbar'; import { useExtensionsContext } from 'src/extensions/ExtensionsContext'; import ExtensionsManager from 'src/extensions/ExtensionsManager'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; @@ -215,7 +215,18 @@ const SouthPane = ({ ...contributions.map(contribution => ({ key: contribution.id, label: contribution.name, - children: getView(contribution.id), + children: ( + div:first-of-type { + padding-bottom: ${theme.sizeUnit * 2}px; + } + `} + > + + {getView(contribution.id)} + + ), forceRender: true, closable: false, })), @@ -231,8 +242,7 @@ const SouthPane = ({ padding: 8px; `} > - - + ), }} diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index 5fe4f5367d9..da2caaf006b 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -760,23 +760,19 @@ const SqlEditor: FC = ({ stopQuery={stopQuery} overlayCreateAsMenu={showMenu ? runMenuBtn : null} /> - - - + {isFeatureEnabled(FeatureFlag.EstimateQueryCost) && database?.allows_cost_estimate && ( - - - + )} ({ +jest.mock('src/components/PanelToolbar', () => ({ __esModule: true, default: ({ - children, viewId, - primary, - secondary, - defaultItems, + defaultPrimaryActions, + defaultSecondaryActions, }: { - children?: React.ReactNode; viewId: string; - primary?: boolean; - secondary?: boolean; - defaultItems?: MenuItemType[]; + defaultPrimaryActions?: React.ReactNode; + defaultSecondaryActions?: MenuItemType[]; }) => ( - {children} + {defaultPrimaryActions} ), })); @@ -63,30 +57,23 @@ const setup = (props?: Partial) => test('renders SqlEditorTopBar component', () => { setup(); - const menuExtensions = screen.getAllByTestId('mock-menu-extension'); - expect(menuExtensions).toHaveLength(2); + const panelToolbar = screen.getByTestId('mock-panel-toolbar'); + expect(panelToolbar).toBeInTheDocument(); }); -test('renders primary MenuListExtension with correct props', () => { +test('renders PanelToolbar with correct viewId', () => { setup(); - const menuExtensions = screen.getAllByTestId('mock-menu-extension'); - const primaryExtension = menuExtensions[0]; - - expect(primaryExtension).toHaveAttribute('data-view-id', 'sqllab.editor'); - expect(primaryExtension).toHaveAttribute('data-primary', 'true'); + const panelToolbar = screen.getByTestId('mock-panel-toolbar'); + expect(panelToolbar).toHaveAttribute('data-view-id', 'sqllab.editor'); }); -test('renders secondary MenuListExtension with correct props', () => { +test('renders PanelToolbar with correct secondary actions count', () => { setup(); - const menuExtensions = screen.getAllByTestId('mock-menu-extension'); - const secondaryExtension = menuExtensions[1]; - - expect(secondaryExtension).toHaveAttribute('data-view-id', 'sqllab.editor'); - expect(secondaryExtension).toHaveAttribute('data-secondary', 'true'); - expect(secondaryExtension).toHaveAttribute('data-default-items-count', '2'); + const panelToolbar = screen.getByTestId('mock-panel-toolbar'); + expect(panelToolbar).toHaveAttribute('data-default-secondary-count', '2'); }); -test('renders defaultPrimaryActions as children of primary MenuListExtension', () => { +test('renders defaultPrimaryActions', () => { setup(); expect( screen.getByRole('button', { name: 'Primary Action' }), @@ -114,17 +101,6 @@ test('renders with custom primary actions', () => { test('renders with empty secondary actions', () => { setup({ defaultSecondaryActions: [] }); - const menuExtensions = screen.getAllByTestId('mock-menu-extension'); - const secondaryExtension = menuExtensions[1]; - - expect(secondaryExtension).toHaveAttribute('data-default-items-count', '0'); -}); - -test('passes correct viewId (ViewContribution.Editor) to MenuListExtension', () => { - setup(); - const menuExtensions = screen.getAllByTestId('mock-menu-extension'); - - menuExtensions.forEach(extension => { - expect(extension).toHaveAttribute('data-view-id', 'sqllab.editor'); - }); + const panelToolbar = screen.getByTestId('mock-panel-toolbar'); + expect(panelToolbar).toHaveAttribute('data-default-secondary-count', '0'); }); diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx index f8b7ead860b..0a3f00da3f9 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorTopBar/index.tsx @@ -16,25 +16,21 @@ * specific language governing permissions and limitations * under the License. */ -import { Divider, Flex } from '@superset-ui/core/components'; +import { Flex } from '@superset-ui/core/components'; import { styled } from '@apache-superset/core/ui'; +import { MenuItemType } from '@superset-ui/core/components/Menu'; import { ViewContribution } from 'src/SqlLab/contributions'; -import MenuListExtension, { - type MenuListExtensionProps, -} from 'src/components/MenuListExtension'; +import PanelToolbar from 'src/components/PanelToolbar'; const StyledFlex = styled(Flex)` margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px; - - & .ant-divider { - margin: ${({ theme }) => theme.sizeUnit * 2}px 0; - height: ${({ theme }) => theme.sizeUnit * 6}px; - } + padding: ${({ theme }) => theme.sizeUnit}px 0; `; + export interface SqlEditorTopBarProps { queryEditorId: string; defaultPrimaryActions: React.ReactNode; - defaultSecondaryActions: MenuListExtensionProps['defaultItems']; + defaultSecondaryActions: MenuItemType[]; } const SqlEditorTopBar = ({ @@ -43,18 +39,11 @@ const SqlEditorTopBar = ({ }: SqlEditorTopBarProps) => ( - - - {defaultPrimaryActions} - - - - - ); diff --git a/superset-frontend/src/SqlLab/contributions.ts b/superset-frontend/src/SqlLab/contributions.ts index b9549bed28a..bdaf184e325 100644 --- a/superset-frontend/src/SqlLab/contributions.ts +++ b/superset-frontend/src/SqlLab/contributions.ts @@ -21,4 +21,6 @@ export enum ViewContribution { Panels = 'sqllab.panels', Editor = 'sqllab.editor', StatusBar = 'sqllab.statusBar', + Results = 'sqllab.results', + QueryHistory = 'sqllab.queryHistory', } diff --git a/superset-frontend/src/components/MenuListExtension/MenuListExtension.test.tsx b/superset-frontend/src/components/MenuListExtension/MenuListExtension.test.tsx deleted file mode 100644 index 853c58c8aaf..00000000000 --- a/superset-frontend/src/components/MenuListExtension/MenuListExtension.test.tsx +++ /dev/null @@ -1,374 +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 { render, screen, waitFor } from 'spec/helpers/testing-library'; -import userEvent from '@testing-library/user-event'; -import type { contributions, core } from '@apache-superset/core'; -import ExtensionsManager from 'src/extensions/ExtensionsManager'; -import { commands } from 'src/core'; -import MenuListExtension from '.'; - -jest.mock('src/core', () => ({ - commands: { - executeCommand: jest.fn(), - }, -})); - -function createMockCommand( - command: string, - overrides: Partial = {}, -): contributions.CommandContribution { - return { - command, - icon: 'PlusOutlined', - title: `${command} Title`, - description: `${command} description`, - ...overrides, - }; -} - -function createMockMenuItem( - view: string, - command: string, -): contributions.MenuItem { - return { - view, - command, - }; -} - -function createMockMenu( - overrides: Partial = {}, -): contributions.MenuContribution { - return { - context: [], - primary: [], - secondary: [], - ...overrides, - }; -} - -function createMockExtension( - options: Partial & { - commands?: contributions.CommandContribution[]; - menus?: Record; - } = {}, -): core.Extension { - const { - id = 'test-extension', - name = 'Test Extension', - commands: cmds = [], - menus = {}, - } = options; - - return { - id, - name, - description: 'A test extension', - version: '1.0.0', - dependencies: [], - remoteEntry: '', - exposedModules: [], - extensionDependencies: [], - contributions: { - commands: cmds, - menus, - views: {}, - }, - activate: jest.fn(), - deactivate: jest.fn(), - }; -} - -function setupActivatedExtension( - manager: ExtensionsManager, - extension: core.Extension, -) { - const context = { disposables: [] }; - (manager as any).contextIndex.set(extension.id, context); - (manager as any).extensionContributions.set(extension.id, { - commands: extension.contributions.commands, - menus: extension.contributions.menus, - views: extension.contributions.views, - }); -} - -async function createActivatedExtension( - manager: ExtensionsManager, - extensionOptions: Parameters[0] = {}, -): Promise { - const mockExtension = createMockExtension(extensionOptions); - await manager.initializeExtension(mockExtension); - setupActivatedExtension(manager, mockExtension); - return mockExtension; -} - -const TEST_VIEW_ID = 'test.menu'; - -beforeEach(() => { - (ExtensionsManager as any).instance = undefined; - jest.clearAllMocks(); -}); - -afterEach(() => { - (ExtensionsManager as any).instance = undefined; -}); - -test('renders children when primary mode with no extensions', () => { - render( - - Child Button - , - ); - - expect( - screen.getByRole('button', { name: 'Child Button' }), - ).toBeInTheDocument(); -}); - -test('renders primary actions from extension contributions', async () => { - const manager = ExtensionsManager.getInstance(); - - await createActivatedExtension(manager, { - commands: [createMockCommand('test.action')], - menus: { - [TEST_VIEW_ID]: createMockMenu({ - primary: [createMockMenuItem('test-view', 'test.action')], - }), - }, - }); - - render(); - - expect(screen.getByText('test.action Title')).toBeInTheDocument(); -}); - -test('renders primary actions with children', async () => { - const manager = ExtensionsManager.getInstance(); - - await createActivatedExtension(manager, { - commands: [createMockCommand('test.action')], - menus: { - [TEST_VIEW_ID]: createMockMenu({ - primary: [createMockMenuItem('test-view', 'test.action')], - }), - }, - }); - - render( - - Child Button - , - ); - - expect(screen.getByText('test.action Title')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Child Button' }), - ).toBeInTheDocument(); -}); - -test('hides title in compact mode for primary actions', async () => { - const manager = ExtensionsManager.getInstance(); - - await createActivatedExtension(manager, { - commands: [createMockCommand('test.action')], - menus: { - [TEST_VIEW_ID]: createMockMenu({ - primary: [createMockMenuItem('test-view', 'test.action')], - }), - }, - }); - - render(); - - expect(screen.queryByText('test.action Title')).not.toBeInTheDocument(); - expect(screen.getByRole('button')).toBeInTheDocument(); -}); - -test('executes command when primary action button is clicked', async () => { - const manager = ExtensionsManager.getInstance(); - - await createActivatedExtension(manager, { - commands: [createMockCommand('test.action')], - menus: { - [TEST_VIEW_ID]: createMockMenu({ - primary: [createMockMenuItem('test-view', 'test.action')], - }), - }, - }); - - render(); - - const button = screen.getByRole('button', { name: 'test.action Title' }); - await userEvent.click(button); - - expect(commands.executeCommand).toHaveBeenCalledWith('test.action'); -}); - -test('returns null when secondary mode with no actions and no defaultItems', () => { - const { container } = render( - , - ); - - expect(container).toBeEmptyDOMElement(); -}); - -test('renders dropdown button when secondary mode with defaultItems', () => { - render( - , - ); - - expect(screen.getByRole('button')).toBeInTheDocument(); -}); - -test('renders dropdown menu with defaultItems when clicked', async () => { - render( - , - ); - - const dropdownButton = screen.getByRole('button'); - await userEvent.click(dropdownButton); - - await waitFor(() => { - expect(screen.getByText('Item 1')).toBeInTheDocument(); - expect(screen.getByText('Item 2')).toBeInTheDocument(); - }); -}); - -test('renders secondary actions from extension contributions', async () => { - const manager = ExtensionsManager.getInstance(); - - await createActivatedExtension(manager, { - commands: [createMockCommand('test.secondary')], - menus: { - [TEST_VIEW_ID]: createMockMenu({ - secondary: [createMockMenuItem('test-view', 'test.secondary')], - }), - }, - }); - - render(); - - const dropdownButton = screen.getByRole('button'); - await userEvent.click(dropdownButton); - - await waitFor(() => { - expect(screen.getByText('test.secondary Title')).toBeInTheDocument(); - }); -}); - -test('merges extension secondary actions with defaultItems', async () => { - const manager = ExtensionsManager.getInstance(); - - await createActivatedExtension(manager, { - commands: [createMockCommand('test.secondary')], - menus: { - [TEST_VIEW_ID]: createMockMenu({ - secondary: [createMockMenuItem('test-view', 'test.secondary')], - }), - }, - }); - - render( - , - ); - - const dropdownButton = screen.getByRole('button'); - await userEvent.click(dropdownButton); - - await waitFor(() => { - expect(screen.getByText('test.secondary Title')).toBeInTheDocument(); - expect(screen.getByText('Default Item')).toBeInTheDocument(); - }); -}); - -test('executes command when secondary menu item is clicked', async () => { - const manager = ExtensionsManager.getInstance(); - - await createActivatedExtension(manager, { - commands: [createMockCommand('test.secondary')], - menus: { - [TEST_VIEW_ID]: createMockMenu({ - secondary: [createMockMenuItem('test-view', 'test.secondary')], - }), - }, - }); - - render(); - - const dropdownButton = screen.getByRole('button'); - await userEvent.click(dropdownButton); - - await waitFor(() => { - expect(screen.getByText('test.secondary Title')).toBeInTheDocument(); - }); - - const menuItem = screen.getByText('test.secondary Title'); - await userEvent.click(menuItem); - - expect(commands.executeCommand).toHaveBeenCalledWith('test.secondary'); -}); - -test('renders multiple primary actions from multiple contributions', async () => { - const manager = ExtensionsManager.getInstance(); - - await createActivatedExtension(manager, { - commands: [ - createMockCommand('test.action1'), - createMockCommand('test.action2'), - ], - menus: { - [TEST_VIEW_ID]: createMockMenu({ - primary: [ - createMockMenuItem('test-view1', 'test.action1'), - createMockMenuItem('test-view2', 'test.action2'), - ], - }), - }, - }); - - render(); - - expect(await screen.findByText('test.action1 Title')).toBeInTheDocument(); - expect(screen.getByText('test.action2 Title')).toBeInTheDocument(); -}); - -test('handles viewId with no matching contributions', () => { - render( - - Fallback - , - ); - - expect(screen.getByRole('button', { name: 'Fallback' })).toBeInTheDocument(); -}); diff --git a/superset-frontend/src/components/MenuListExtension/index.tsx b/superset-frontend/src/components/MenuListExtension/index.tsx deleted file mode 100644 index 98608f6a291..00000000000 --- a/superset-frontend/src/components/MenuListExtension/index.tsx +++ /dev/null @@ -1,157 +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 { useMemo } from 'react'; -import { css, useTheme } from '@apache-superset/core/ui'; -import { Button, Dropdown } from '@superset-ui/core/components'; -import { Menu, MenuItemType } from '@superset-ui/core/components/Menu'; -import { Icons } from '@superset-ui/core/components/Icons'; -import { commands } from 'src/core'; -import ExtensionsManager from 'src/extensions/ExtensionsManager'; - -export type MenuListExtensionProps = { - viewId: string; -} & ( - | { - primary: boolean; - secondary?: never; - children?: React.ReactNode; - defaultItems?: never; - compactMode?: boolean; - } - | { - primary?: never; - secondary: boolean; - children?: never; - defaultItems?: MenuItemType[]; - compactMode?: never; - } -); - -const MenuListExtension = ({ - viewId, - primary, - secondary, - defaultItems, - children, - compactMode, -}: MenuListExtensionProps) => { - const theme = useTheme(); - const contributions = - ExtensionsManager.getInstance().getMenuContributions(viewId); - - const actions = primary ? contributions?.primary : contributions?.secondary; - const primaryActions = useMemo( - () => - primary - ? (actions || []).map(contribution => { - const command = - ExtensionsManager.getInstance().getCommandContribution( - contribution.command, - )!; - if (!command?.icon) { - return null; - } - const Icon = - (Icons as Record)[ - command.icon - ] ?? Icons.FileOutlined; - - return ( - commands.executeCommand(command?.command)} - tooltip={command?.description ?? command?.title} - icon={} - buttonSize="small" - aria-label={command?.title} - {...(compactMode && { variant: 'text', color: 'primary' })} - > - {!compactMode ? command?.title : undefined} - - ); - }) - : [], - [actions, primary, compactMode], - ); - const secondaryActions = useMemo( - () => - secondary - ? (actions || []) - .map(contribution => { - const command = - ExtensionsManager.getInstance().getCommandContribution( - contribution.command, - )!; - if (!command) { - return null; - } - return { - key: command.command, - label: command.title, - title: command.description, - onClick: () => commands.executeCommand(command.command), - } as MenuItemType; - }) - .concat(...(defaultItems || [])) - .filter(Boolean) - : [], - [actions, secondary, defaultItems], - ); - - if (secondary && secondaryActions.length === 0) { - return null; - } - - if (secondary) { - return ( - ( - div { - gap: ${theme.sizeUnit * 4}px; - } - `} - items={secondaryActions} - /> - )} - trigger={['click']} - > - - - - - ); - } - return ( - <> - {primaryActions} - {children} - > - ); -}; - -export default MenuListExtension; diff --git a/superset-frontend/src/components/PanelToolbar/index.tsx b/superset-frontend/src/components/PanelToolbar/index.tsx new file mode 100644 index 00000000000..7f88197811b --- /dev/null +++ b/superset-frontend/src/components/PanelToolbar/index.tsx @@ -0,0 +1,165 @@ +/** + * 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 { useMemo } from 'react'; +import { css, useTheme } from '@apache-superset/core/ui'; +import { Button, Divider, Dropdown } from '@superset-ui/core/components'; +import { Menu, MenuItemType } from '@superset-ui/core/components/Menu'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { commands } from 'src/core'; +import ExtensionsManager from 'src/extensions/ExtensionsManager'; + +export interface PanelToolbarProps { + viewId: string; + defaultPrimaryActions?: React.ReactNode; + defaultSecondaryActions?: MenuItemType[]; +} + +const PanelToolbar = ({ + viewId, + defaultPrimaryActions, + defaultSecondaryActions, +}: PanelToolbarProps) => { + const theme = useTheme(); + const contributions = + ExtensionsManager.getInstance().getMenuContributions(viewId); + + const primaryContributions = contributions?.primary || []; + const secondaryContributions = contributions?.secondary || []; + + const extensionPrimaryActions = useMemo( + () => + primaryContributions + .map(contribution => { + const command = + ExtensionsManager.getInstance().getCommandContribution( + contribution.command, + )!; + if (!command?.icon) { + return null; + } + const Icon = + (Icons as Record)[ + command.icon + ] ?? Icons.FileOutlined; + + return ( + commands.executeCommand(command?.command)} + tooltip={command?.description ?? command?.title} + icon={} + buttonSize="small" + aria-label={command?.title} + variant="text" + color="primary" + /> + ); + }) + .filter(Boolean), + [primaryContributions], + ); + + const secondaryActions = useMemo( + () => + secondaryContributions + .map(contribution => { + const command = + ExtensionsManager.getInstance().getCommandContribution( + contribution.command, + )!; + if (!command) { + return null; + } + return { + key: command.command, + label: command.title, + title: command.description, + onClick: () => commands.executeCommand(command.command), + } as MenuItemType; + }) + .filter(Boolean) + .concat(defaultSecondaryActions || []), + [secondaryContributions, defaultSecondaryActions], + ); + + const hasPrimaryActions = + !!defaultPrimaryActions || extensionPrimaryActions.length > 0; + const hasSecondaryActions = secondaryActions.length > 0; + + // If no actions at all, render nothing + if (!hasPrimaryActions && !hasSecondaryActions) { + return null; + } + + const toolbarStyles = css` + display: flex; + align-items: center; + gap: ${theme.sizeUnit * 2}px; + + & .ant-divider { + height: ${theme.sizeUnit * 6}px; + margin: 0; + } + + & .superset-button { + margin-left: 0 !important; + min-width: ${theme.sizeUnit * 8}px; + } + `; + + return ( + + {hasPrimaryActions && ( + <> + {defaultPrimaryActions} + {extensionPrimaryActions} + > + )} + {hasPrimaryActions && hasSecondaryActions && } + {hasSecondaryActions && ( + ( + div { + gap: ${theme.sizeUnit * 4}px; + } + `} + items={secondaryActions} + /> + )} + trigger={['click']} + > + + + + + )} + + ); +}; + +export default PanelToolbar;