diff --git a/UPDATING.md b/UPDATING.md index fcd69c18fd4..0680e44fb61 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -23,6 +23,10 @@ This file documents any backwards-incompatible changes in Superset and assists people when migrating to a new version. ## Next +- [34536](https://github.com/apache/superset/pull/34536): The `ENVIRONMENT_TAG_CONFIG` color values have changed to support only Ant Design semantic colors. Update your `superset_config.py`: + - Change `"error.base"` to just `"error"` after this PR + - Change any hex color values to one of: `"success"`, `"processing"`, `"error"`, `"warning"`, `"default"` + - Custom colors are no longer supported to maintain consistency with Ant Design components - [34561](https://github.com/apache/superset/pull/34561) Added tiled screenshot functionality for Playwright-based reports to handle large dashboards more efficiently. When enabled (default: `SCREENSHOT_TILED_ENABLED = True`), dashboards with 20+ charts or height exceeding 5000px will be captured using multiple viewport-sized tiles and combined into a single image. This improves report generation performance and reliability for large dashboards. Note: Pillow is now a required dependency (previously optional) to support image processing for tiled screenshots. `thumbnails` optional dependency is now deprecated and will be removed in the next major release (7.0). 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 d1ead96dc3d..578405574c0 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts @@ -166,7 +166,6 @@ export * from './Table'; export * from './TableView'; export * from './Tag'; export * from './TelemetryPixel'; -export * from './ThemeSubMenu'; export * from './UnsavedChangesModal'; export * from './constants'; export * from './Result'; diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.test.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.test.tsx deleted file mode 100644 index c015f263567..00000000000 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.test.tsx +++ /dev/null @@ -1,79 +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 { SyntheticEvent } from 'react'; -import { - render, - screen, - userEvent, - waitFor, -} from 'spec/helpers/testing-library'; -import { Menu } from '@superset-ui/core/components/Menu'; -import downloadAsImage from 'src/utils/downloadAsImage'; -import DownloadAsImage from './DownloadAsImage'; - -const mockAddDangerToast = jest.fn(); - -jest.mock('src/utils/downloadAsImage', () => ({ - __esModule: true, - default: jest.fn(() => (_e: SyntheticEvent) => console.log(_e)), -})); - -jest.mock('src/components/MessageToasts/withToasts', () => ({ - useToasts: () => ({ - addDangerToast: mockAddDangerToast, - }), -})); - -const createProps = () => ({ - text: 'Download as Image', - dashboardTitle: 'Test Dashboard', - logEvent: jest.fn(), -}); - -const renderComponent = () => { - render( - - - , - { - useRedux: true, - }, - ); -}; - -test('Should call download image on click', async () => { - renderComponent(); - await waitFor(() => { - expect(downloadAsImage).toHaveBeenCalledTimes(0); - expect(mockAddDangerToast).toHaveBeenCalledTimes(0); - }); - - userEvent.click(screen.getByRole('menuitem', { name: 'Download as Image' })); - - await waitFor(() => { - expect(downloadAsImage).toHaveBeenCalledTimes(1); - expect(mockAddDangerToast).toHaveBeenCalledTimes(0); - }); -}); - -test('Component is rendered with role="menuitem"', async () => { - renderComponent(); - const button = screen.getByRole('menuitem', { name: 'Download as Image' }); - expect(button).toBeInTheDocument(); -}); diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx deleted file mode 100644 index a992bbb5d4a..00000000000 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsImage.tsx +++ /dev/null @@ -1,59 +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 { SyntheticEvent } from 'react'; -import { logging, t } from '@superset-ui/core'; -import { Menu } from '@superset-ui/core/components/Menu'; -import { LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils'; -import downloadAsImage from 'src/utils/downloadAsImage'; -import { useToasts } from 'src/components/MessageToasts/withToasts'; - -export default function DownloadAsImage({ - text, - logEvent, - dashboardTitle, - ...props -}: { - text: string; - dashboardTitle: string; - logEvent?: Function; -}) { - const SCREENSHOT_NODE_SELECTOR = '.dashboard'; - const { addDangerToast } = useToasts(); - const onDownloadImage = async (e: SyntheticEvent) => { - try { - downloadAsImage(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e); - } catch (error) { - logging.error(error); - addDangerToast(t('Sorry, something went wrong. Try again later.')); - } - logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE); - }; - - return ( - { - onDownloadImage(e.domEvent); - }} - {...props} - > - {text} - - ); -} diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.test.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.test.tsx deleted file mode 100644 index cead7cf4473..00000000000 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.test.tsx +++ /dev/null @@ -1,77 +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 { SyntheticEvent } from 'react'; -import { - render, - screen, - userEvent, - waitFor, -} from 'spec/helpers/testing-library'; -import { Menu } from '@superset-ui/core/components/Menu'; -import downloadAsPdf from 'src/utils/downloadAsPdf'; -import DownloadAsPdf from './DownloadAsPdf'; - -const mockAddDangerToast = jest.fn(); - -jest.mock('src/utils/downloadAsPdf', () => ({ - __esModule: true, - default: jest.fn(() => (_e: SyntheticEvent) => console.log(_e)), -})); - -jest.mock('src/components/MessageToasts/withToasts', () => ({ - useToasts: () => ({ - addDangerToast: mockAddDangerToast, - }), -})); - -const createProps = () => ({ - text: 'Export as PDF', - dashboardTitle: 'Test Dashboard', - logEvent: jest.fn(), -}); - -const renderComponent = () => { - render( - - - , - { useRedux: true }, - ); -}; - -test('Should call download pdf on click', async () => { - renderComponent(); - await waitFor(() => { - expect(downloadAsPdf).toHaveBeenCalledTimes(0); - expect(mockAddDangerToast).toHaveBeenCalledTimes(0); - }); - - userEvent.click(screen.getByRole('menuitem', { name: 'Export as PDF' })); - - await waitFor(() => { - expect(downloadAsPdf).toHaveBeenCalledTimes(1); - expect(mockAddDangerToast).toHaveBeenCalledTimes(0); - }); -}); - -test('Component is rendered with role="menuitem"', async () => { - renderComponent(); - const button = screen.getByRole('menuitem', { name: 'Export as PDF' }); - expect(button).toBeInTheDocument(); -}); diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.tsx deleted file mode 100644 index 40fe21e0221..00000000000 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadAsPdf.tsx +++ /dev/null @@ -1,59 +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 { SyntheticEvent } from 'react'; -import { logging, t } from '@superset-ui/core'; -import { Menu } from '@superset-ui/core/components/Menu'; -import downloadAsPdf from 'src/utils/downloadAsPdf'; -import { LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF } from 'src/logger/LogUtils'; -import { useToasts } from 'src/components/MessageToasts/withToasts'; - -export default function DownloadAsPdf({ - text, - logEvent, - dashboardTitle, - ...props -}: { - text: string; - dashboardTitle: string; - logEvent?: Function; -}) { - const SCREENSHOT_NODE_SELECTOR = '.dashboard'; - const { addDangerToast } = useToasts(); - const onDownloadPdf = async (e: SyntheticEvent) => { - try { - downloadAsPdf(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e); - } catch (error) { - logging.error(error); - addDangerToast(t('Sorry, something went wrong. Try again later.')); - } - logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF); - }; - - return ( - { - onDownloadPdf(e.domEvent); - }} - {...props} - > - {text} - - ); -} diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.test.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.test.tsx deleted file mode 100644 index 5707c6734a8..00000000000 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.test.tsx +++ /dev/null @@ -1,216 +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, - userEvent, - waitFor, -} from 'spec/helpers/testing-library'; -import { Menu } from '@superset-ui/core/components/Menu'; -import fetchMock from 'fetch-mock'; -import { logging } from '@superset-ui/core'; -import { DownloadScreenshotFormat } from './types'; -import DownloadScreenshot from './DownloadScreenshot'; - -const mockAddDangerToast = jest.fn(); -const mockLogEvent = jest.fn(); -const mockAddSuccessToast = jest.fn(); -const mockAddInfoToast = jest.fn(); - -jest.spyOn(logging, 'error').mockImplementation(() => {}); - -jest.mock('src/components/MessageToasts/withToasts', () => ({ - useToasts: () => ({ - addDangerToast: mockAddDangerToast, - addSuccessToast: mockAddSuccessToast, - addInfoToast: mockAddInfoToast, - }), -})); - -const defaultProps = () => ({ - text: 'Download', - dashboardId: 123, - format: DownloadScreenshotFormat.PDF, - logEvent: mockLogEvent, -}); - -const renderComponent = () => { - render( - - - , - { - useRedux: true, - }, - ); -}; - -describe('DownloadScreenshot component', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useRealTimers(); - fetchMock.restore(); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - test('renders correctly with the given text', () => { - renderComponent(); - expect(screen.getByText('Download')).toBeInTheDocument(); - }); - - test('button renders with role="button"', async () => { - renderComponent(); - const button = screen.getByRole('button', { name: 'Download' }); - expect(button).toBeInTheDocument(); - }); - - test('displays error message when API call fails', async () => { - const props = defaultProps(); - - fetchMock.post( - `glob:*/api/v1/dashboard/${props.dashboardId}/cache_dashboard_screenshot/`, - { - status: 400, - body: {}, - }, - ); - - renderComponent(); - - userEvent.click(screen.getByRole('button', { name: 'Download' })); - - await waitFor(() => { - expect(mockAddDangerToast).toHaveBeenCalledWith( - 'The screenshot could not be downloaded. Please, try again later.', - ); - }); - }); - - test('displays success message when API call succeeds', async () => { - const props = defaultProps(); - fetchMock.post( - `glob:*/api/v1/dashboard/${props.dashboardId}/cache_dashboard_screenshot/`, - { - status: 200, - body: { - image_url: 'mocked_image_url', - cache_key: 'mocked_cache_key', - }, - }, - ); - - fetchMock.get( - `glob:*/api/v1/dashboard/${props.dashboardId}/screenshot/mocked_cache_key/?download_format=pdf`, - { - status: 200, - body: {}, - }, - ); - - renderComponent(); - - userEvent.click(screen.getByRole('button', { name: 'Download' })); - - await waitFor(() => { - expect(mockAddInfoToast).toHaveBeenCalledWith( - 'The screenshot is being generated. Please, do not leave the page.', - { - noDuplicate: true, - }, - ); - }); - }); - - test('throws error when no image cache key is provided', async () => { - const props = defaultProps(); - fetchMock.post( - `glob:*/api/v1/dashboard/${props.dashboardId}/cache_dashboard_screenshot/`, - { - status: 200, - body: { - cache_key: '', - }, - }, - ); - - renderComponent(); - - // Simulate the user clicking the download button - userEvent.click(screen.getByRole('button', { name: 'Download' })); - - await waitFor(() => { - expect(mockAddDangerToast).toHaveBeenCalledWith( - 'The screenshot could not be downloaded. Please, try again later.', - ); - }); - }); - - test('displays success message when image retrieval succeeds', async () => { - const props = defaultProps(); - fetchMock.post( - `glob:*/api/v1/dashboard/${props.dashboardId}/cache_dashboard_screenshot/`, - { - status: 200, - body: { - image_url: 'mocked_image_url', - cache_key: 'mocked_cache_key', - }, - }, - ); - - fetchMock.get( - `glob:*/api/v1/dashboard/${props.dashboardId}/screenshot/mocked_cache_key/?download_format=pdf`, - { - status: 200, - headers: { - 'Content-Type': 'application/pdf', - }, - body: new Blob([], { type: 'application/pdf' }), - }, - ); - - global.URL.createObjectURL = jest.fn(() => 'mockedObjectURL'); - global.URL.revokeObjectURL = jest.fn(); - - // Render the component - renderComponent(); - - // Simulate the user clicking the download button - userEvent.click(screen.getByRole('button', { name: 'Download' })); - - await waitFor(() => { - expect( - fetchMock.calls( - `glob:*/api/v1/dashboard/${props.dashboardId}/screenshot/mocked_cache_key/?download_format=pdf`, - ).length, - ).toBe(1); - }); - - // Wait for the successful image retrieval message - await waitFor(() => { - expect(mockAddSuccessToast).toHaveBeenCalledWith( - 'The screenshot has been downloaded.', - ); - }); - }); -}); diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.tsx deleted file mode 100644 index 8a47e042223..00000000000 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.tsx +++ /dev/null @@ -1,203 +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 { - logging, - t, - SupersetClient, - SupersetApiError, -} from '@superset-ui/core'; -import { Menu } from '@superset-ui/core/components/Menu'; -import { - LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE, - LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF, -} from 'src/logger/LogUtils'; -import { RootState } from 'src/dashboard/types'; -import { useSelector } from 'react-redux'; -import { useToasts } from 'src/components/MessageToasts/withToasts'; -import { last } from 'lodash'; -import { getDashboardUrlParams } from 'src/utils/urlUtils'; -import { useCallback, useEffect, useRef } from 'react'; -import { DownloadScreenshotFormat } from './types'; - -const RETRY_INTERVAL = 3000; -const MAX_RETRIES = 30; - -export default function DownloadScreenshot({ - text, - logEvent, - dashboardId, - format, - ...rest -}: { - text: string; - dashboardId: number; - logEvent?: Function; - format: string; -}) { - const activeTabs = useSelector( - (state: RootState) => state.dashboardState.activeTabs || undefined, - ); - const anchor = useSelector( - (state: RootState) => - last(state.dashboardState.directPathToChild) || undefined, - ); - const dataMask = useSelector( - (state: RootState) => state.dataMask || undefined, - ); - const { addDangerToast, addSuccessToast, addInfoToast } = useToasts(); - const currentIntervalIds = useRef([]); - - const printLoadingToast = () => - addInfoToast( - t('The screenshot is being generated. Please, do not leave the page.'), - { - noDuplicate: true, - }, - ); - - const printFailureToast = useCallback( - () => - addDangerToast( - t('The screenshot could not be downloaded. Please, try again later.'), - ), - [addDangerToast], - ); - - const printSuccessToast = useCallback( - () => addSuccessToast(t('The screenshot has been downloaded.')), - [addSuccessToast], - ); - - const stopIntervals = useCallback( - (message?: 'success' | 'failure') => { - currentIntervalIds.current.forEach(clearInterval); - - if (message === 'failure') { - printFailureToast(); - } - if (message === 'success') { - printSuccessToast(); - } - }, - [printFailureToast, printSuccessToast], - ); - - const onDownloadScreenshot = () => { - let retries = 0; - - const toastIntervalId = setInterval( - () => printLoadingToast(), - RETRY_INTERVAL, - ); - - currentIntervalIds.current = [ - ...(currentIntervalIds.current || []), - toastIntervalId, - ]; - - printLoadingToast(); - - // this function checks if the image is ready - const checkImageReady = (cacheKey: string) => - SupersetClient.get({ - endpoint: `/api/v1/dashboard/${dashboardId}/screenshot/${cacheKey}/?download_format=${format}`, - headers: { Accept: 'application/pdf, image/png' }, - parseMethod: 'raw', - }) - .then((response: Response) => response.blob()) - .then(blob => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `screenshot.${format}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - stopIntervals('success'); - }) - .catch(err => { - if ((err as SupersetApiError).status === 404) { - throw new Error('Image not ready'); - } - }); - - const fetchImageWithRetry = (cacheKey: string) => { - if (retries >= MAX_RETRIES) { - stopIntervals('failure'); - logging.error('Max retries reached'); - return; - } - checkImageReady(cacheKey).catch(() => { - retries += 1; - }); - }; - - SupersetClient.post({ - endpoint: `/api/v1/dashboard/${dashboardId}/cache_dashboard_screenshot/`, - jsonPayload: { - anchor, - activeTabs, - dataMask, - urlParams: getDashboardUrlParams(['edit']), - }, - }) - .then(({ json }) => { - const cacheKey = json?.cache_key; - if (!cacheKey) { - throw new Error('No image URL in response'); - } - const retryIntervalId = setInterval(() => { - fetchImageWithRetry(cacheKey); - }, RETRY_INTERVAL); - currentIntervalIds.current.push(retryIntervalId); - fetchImageWithRetry(cacheKey); - }) - .catch(error => { - logging.error(error); - stopIntervals('failure'); - }) - .finally(() => { - logEvent?.( - format === DownloadScreenshotFormat.PNG - ? LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE - : LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF, - ); - }); - }; - - useEffect( - () => () => { - if (currentIntervalIds.current.length > 0) { - stopIntervals(); - } - currentIntervalIds.current = []; - }, - [stopIntervals], - ); - - return ( - -
- {text} -
-
- ); -} diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx index 0a5d5b84886..b8f8f275543 100644 --- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx @@ -317,47 +317,59 @@ class DatasourceControl extends PureComponent { sql: datasource.sql, }; - const defaultDatasourceMenu = ( - - {this.props.isEditable && !isMissingDatasource && ( - - {!allowEdit ? ( - - {editText} - - ) : ( - editText + const defaultDatasourceMenuItems = []; + if (this.props.isEditable && !isMissingDatasource) { + defaultDatasourceMenuItems.push({ + key: EDIT_DATASET, + label: !allowEdit ? ( + - )} - {t('Swap dataset')} - {!isMissingDatasource && canAccessSqlLab && ( - - - {t('View in SQL Lab')} - - - )} - + > + {editText} + + ) : ( + editText + ), + disabled: !allowEdit, + ...{ 'data-test': 'edit-dataset' }, + }); + } + + defaultDatasourceMenuItems.push({ + key: CHANGE_DATASET, + label: t('Swap dataset'), + }); + + if (!isMissingDatasource && canAccessSqlLab) { + defaultDatasourceMenuItems.push({ + key: VIEW_IN_SQL_LAB, + label: ( + + {t('View in SQL Lab')} + + ), + }); + } + + const defaultDatasourceMenu = ( + ); - const queryDatasourceMenu = ( - - + const queryDatasourceMenuItems = [ + { + key: QUERY_PREVIEW, + label: ( {t('Query preview')} @@ -378,22 +390,37 @@ class DatasourceControl extends PureComponent { resizable={false} responsive /> - - {canAccessSqlLab && ( - - - {t('View in SQL Lab')} - - - )} - {t('Save as dataset')} - + ), + }, + ]; + + if (canAccessSqlLab) { + queryDatasourceMenuItems.push({ + key: VIEW_IN_SQL_LAB, + label: ( + + {t('View in SQL Lab')} + + ), + }); + } + + queryDatasourceMenuItems.push({ + key: SAVE_AS_DATASET, + label: t('Save as dataset'), + }); + + const queryDatasourceMenu = ( + ); const { health_check_message: healthCheckMessage } = datasource; diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx index 9f0423acf08..04b6ae9e742 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx @@ -16,69 +16,180 @@ * specific language governing permissions and limitations * under the License. */ -import { - render, - screen, - userEvent, - waitFor, -} from 'spec/helpers/testing-library'; -import { Menu } from '@superset-ui/core/components/Menu'; -import DashboardItems from './DashboardsSubMenu'; +import { render, screen } from 'spec/helpers/testing-library'; +import type { MenuItemType } from '@superset-ui/core/components'; +import { useDashboardsMenuItems } from './DashboardsSubMenu'; +import { SEARCH_THRESHOLD } from './index'; -const asyncRender = (numberOfItems: number) => { +const TestDashboardsMenuItems = ({ + chartId, + dashboards, + searchTerm, +}: { + chartId?: number; + dashboards: { id: number; dashboard_title: string }[]; + searchTerm?: string; +}) => { + const menuItems = useDashboardsMenuItems({ + chartId, + dashboards, + searchTerm, + }) as MenuItemType[]; + return ( +
+ {menuItems.map(item => ( +
+ {typeof item.label === 'string' ? item!.label : 'Complex Label'} + {item!.disabled && disabled} +
+ ))} +
+ ); +}; + +const createDashboards = (numberOfItems: number) => { const dashboards = []; for (let i = 1; i <= numberOfItems; i += 1) { dashboards.push({ id: i, dashboard_title: `Dashboard ${i}` }); } - render( - - - - - , - { - useRouter: true, - }, - ); + return dashboards; }; -test('renders a submenu', async () => { - asyncRender(3); - await waitFor(() => { - expect(screen.getByText('Dashboard 1')).toBeInTheDocument(); - expect(screen.getByText('Dashboard 2')).toBeInTheDocument(); - expect(screen.getByText('Dashboard 3')).toBeInTheDocument(); +describe('DashboardsSubMenu', () => { + test('exports SEARCH_THRESHOLD constant', () => { + expect(SEARCH_THRESHOLD).toBe(10); + }); + + test('renders menu items for dashboards', () => { + const dashboards = createDashboards(3); + render( + , + { useRouter: true }, + ); + + expect(screen.getByTestId('menu-item-1')).toBeInTheDocument(); + expect(screen.getByTestId('menu-item-2')).toBeInTheDocument(); + expect(screen.getByTestId('menu-item-3')).toBeInTheDocument(); + }); + + test('filters dashboards based on search term', () => { + const dashboards = createDashboards(20); + render( + , + { useRouter: true }, + ); + + // Should show Dashboard 2, Dashboard 12, and Dashboard 20 + expect(screen.getByTestId('menu-item-2')).toBeInTheDocument(); + expect(screen.getByTestId('menu-item-12')).toBeInTheDocument(); + expect(screen.getByTestId('menu-item-20')).toBeInTheDocument(); + + // Should not show Dashboard 1 + expect(screen.queryByTestId('menu-item-1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('menu-item-3')).not.toBeInTheDocument(); + }); + + test('returns "No results found" when search has no matches', () => { + const dashboards = createDashboards(20); + render( + , + { useRouter: true }, + ); + + expect(screen.getByTestId('menu-item-no-results')).toBeInTheDocument(); + expect(screen.getByText('No results found')).toBeInTheDocument(); + expect(screen.getByTestId('disabled')).toBeInTheDocument(); + }); + + test('returns "None" when no dashboards provided', () => { + render( + , + { useRouter: true }, + ); + + expect(screen.getByTestId('menu-item-no-dashboards')).toBeInTheDocument(); + expect(screen.getByText('None')).toBeInTheDocument(); + expect(screen.getByTestId('disabled')).toBeInTheDocument(); + }); + + test('handles missing chart ID gracefully', () => { + const dashboards = createDashboards(1); + render(, { + useRouter: true, + }); + + expect(screen.getByTestId('menu-item-1')).toBeInTheDocument(); + }); + + test('case-insensitive search filtering', () => { + const dashboards = [ + { id: 1, dashboard_title: 'Sales Dashboard' }, + { id: 2, dashboard_title: 'Marketing Dashboard' }, + { id: 3, dashboard_title: 'Analytics Dashboard' }, + ]; + + render( + , + { useRouter: true }, + ); + + expect(screen.getByTestId('menu-item-1')).toBeInTheDocument(); + expect(screen.queryByTestId('menu-item-2')).not.toBeInTheDocument(); + expect(screen.queryByTestId('menu-item-3')).not.toBeInTheDocument(); + }); + + test('empty search term shows all dashboards', () => { + const dashboards = createDashboards(5); + render( + , + { useRouter: true }, + ); + + expect(screen.getByTestId('menu-item-1')).toBeInTheDocument(); + expect(screen.getByTestId('menu-item-2')).toBeInTheDocument(); + expect(screen.getByTestId('menu-item-3')).toBeInTheDocument(); + expect(screen.getByTestId('menu-item-4')).toBeInTheDocument(); + expect(screen.getByTestId('menu-item-5')).toBeInTheDocument(); + }); + + test('partial string search works correctly', () => { + const dashboards = [ + { id: 1, dashboard_title: 'Revenue Report' }, + { id: 2, dashboard_title: 'User Engagement' }, + { id: 3, dashboard_title: 'Product Performance' }, + ]; + + render( + , + { useRouter: true }, + ); + + expect(screen.getByTestId('menu-item-1')).toBeInTheDocument(); // Revenue Report + expect(screen.queryByTestId('menu-item-2')).not.toBeInTheDocument(); + expect(screen.queryByTestId('menu-item-3')).not.toBeInTheDocument(); }); }); - -test('renders a submenu with search', async () => { - asyncRender(20); - expect(await screen.findByPlaceholderText('Search')).toBeInTheDocument(); -}); - -test('displays a searched value', async () => { - asyncRender(20); - userEvent.type(screen.getByPlaceholderText('Search'), '2'); - expect(await screen.findByText('Dashboard 2')).toBeInTheDocument(); - expect(await screen.findByText('Dashboard 20')).toBeInTheDocument(); -}); - -test('renders a "No results found" message when searching', async () => { - asyncRender(20); - userEvent.type(screen.getByPlaceholderText('Search'), 'unknown'); - expect(await screen.findByText('No results found')).toBeInTheDocument(); -}); - -test('renders a submenu with no dashboards', async () => { - asyncRender(0); - expect(await screen.findByText('None')).toBeInTheDocument(); -}); - -test('shows link icon when hovering', async () => { - asyncRender(3); - expect(screen.queryByRole('img', { name: 'full' })).not.toBeInTheDocument(); - userEvent.hover(await screen.findByText('Dashboard 1')); - expect( - (await screen.findAllByRole('img', { name: 'full' }))[0], - ).toBeInTheDocument(); -}); diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx index 2f7f85c7a7d..b0a66095082 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx @@ -16,130 +16,94 @@ * specific language governing permissions and limitations * under the License. */ -import { useState } from 'react'; +import { useMemo } from 'react'; import { css, t, useTheme } from '@superset-ui/core'; -import { Input } from '@superset-ui/core/components'; +import { MenuItem } from '@superset-ui/core/components/Menu'; import { Icons } from '@superset-ui/core/components/Icons'; -import { Menu } from '@superset-ui/core/components/Menu'; import { Link } from 'react-router-dom'; -export interface DashboardsSubMenuProps { +export interface DashboardsMenuProps { chartId?: number; dashboards?: { id: number; dashboard_title: string }[]; + searchTerm?: string; } -const WIDTH = 220; -const HEIGHT = 300; -const SEARCH_THRESHOLD = 10; - -const DashboardsSubMenu = ({ +export const useDashboardsMenuItems = ({ chartId, dashboards = [], - ...menuProps -}: DashboardsSubMenuProps) => { + searchTerm = '', +}: DashboardsMenuProps): MenuItem[] => { const theme = useTheme(); - const [dashboardSearch, setDashboardSearch] = useState(); - const [hoveredItem, setHoveredItem] = useState(); - const showSearch = dashboards.length > SEARCH_THRESHOLD; - const filteredDashboards = dashboards.filter( - dashboard => - !dashboardSearch || + + const filteredDashboards = useMemo(() => { + if (!searchTerm) return dashboards; + return dashboards.filter(dashboard => dashboard.dashboard_title .toLowerCase() - .includes(dashboardSearch.toLowerCase()), - ); - const noResults = dashboards.length === 0; - const noResultsFound = dashboardSearch && filteredDashboards.length === 0; + .includes(searchTerm.toLowerCase()), + ); + }, [dashboards, searchTerm]); + const urlQueryString = chartId ? `?focused_chart=${chartId}` : ''; - return ( - <> - {showSearch && ( - } - css={css` - width: ${WIDTH}px; - margin: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px; - `} - value={dashboardSearch} - onChange={e => setDashboardSearch(e.currentTarget.value)} - /> - )} -
- {filteredDashboards.map(dashboard => ( - setHoveredItem(dashboard.id)} - onMouseLeave={() => { - if (hoveredItem === dashboard.id) { - setHoveredItem(null); - } - }} - {...menuProps} - > + const noResults = dashboards.length === 0; + const noResultsFound = searchTerm && filteredDashboards.length === 0; + + return useMemo(() => { + const items: MenuItem[] = []; + + if (noResults) { + items.push({ + key: 'no-dashboards', + label: t('None'), + disabled: true, + }); + } else if (noResultsFound) { + items.push({ + key: 'no-results', + label: t('No results found'), + disabled: true, + }); + } else { + filteredDashboards.forEach(dashboard => { + items.push({ + key: String(dashboard.id), + label: (
-
- {dashboard.dashboard_title} -
- + {dashboard.dashboard_title}
+ -
- ))} - {noResultsFound && ( -
- {t('No results found')} -
- )} - {noResults && ( - - {t('None')} - - )} -
- - ); -}; + ), + }); + }); + } -export default DashboardsSubMenu; + return items; + }, [ + filteredDashboards, + urlQueryString, + noResults, + noResultsFound, + theme.sizeUnit, + ]); +}; diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx index 264f01dc99e..40fdd29460e 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx @@ -18,6 +18,7 @@ */ import { useCallback, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { useDebounceValue } from 'src/hooks/useDebounceValue'; import { css, isFeatureEnabled, @@ -27,7 +28,12 @@ import { useTheme, VizType, } from '@superset-ui/core'; -import { Icons, ModalTrigger, Button } from '@superset-ui/core/components'; +import { + Icons, + ModalTrigger, + Button, + Input, +} from '@superset-ui/core/components'; import { Menu } from '@superset-ui/core/components/Menu'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { exportChart, getChartKey } from 'src/explore/exploreUtils'; @@ -46,7 +52,9 @@ import { import exportPivotExcel from 'src/utils/downloadAsPivotExcel'; import ViewQueryModal from '../controls/ViewQueryModal'; import EmbedCodeContent from '../EmbedCodeContent'; -import DashboardsSubMenu from './DashboardsSubMenu'; +import { useDashboardsMenuItems } from './DashboardsSubMenu'; + +export const SEARCH_THRESHOLD = 10; const MENU_KEYS = { EDIT_PROPERTIES: 'edit_properties', @@ -124,6 +132,11 @@ export const useExploreAdditionalActionsMenu = ( const { addDangerToast, addSuccessToast } = useToasts(); const dispatch = useDispatch(); const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [dashboardSearchTerm, setDashboardSearchTerm] = useState(''); + const debouncedDashboardSearchTerm = useDebounceValue( + dashboardSearchTerm, + 300, + ); const chart = useSelector( state => state.charts?.[getChartKey(state.explore)], ); @@ -137,6 +150,15 @@ export const useExploreAdditionalActionsMenu = ( const { datasource } = latestQueryFormData; + // Get dashboard menu items using the hook + const dashboardMenuItems = useDashboardsMenuItems({ + chartId: slice?.slice_id, + dashboards, + searchTerm: debouncedDashboardSearchTerm, + }); + + const showDashboardSearch = dashboards?.length > SEARCH_THRESHOLD; + const shareByEmail = useCallback(async () => { try { const subject = t('Superset Chart'); @@ -225,21 +247,44 @@ export const useExploreAdditionalActionsMenu = ( } // On dashboards submenu + const dashboardsChildren = []; + + // Add search input if needed + if (showDashboardSearch) { + dashboardsChildren.push({ + key: 'dashboard-search', + label: ( + } + css={css` + width: 220px; + margin: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px; + `} + value={dashboardSearchTerm} + onChange={e => setDashboardSearchTerm(e.currentTarget.value)} + onClick={e => e.stopPropagation()} + /> + ), + disabled: true, // Prevent clicks on the search input from closing menu + }); + } + + // Add dashboard items + dashboardMenuItems.forEach(item => { + dashboardsChildren.push(item); + }); + menuItems.push({ key: MENU_KEYS.DASHBOARDS_ADDED_TO, type: 'submenu', label: t('On dashboards'), - children: [ - { - key: 'dashboards-content', - label: ( - - ), - }, - ], + children: dashboardsChildren, + popupStyle: { + maxHeight: '300px', + overflow: 'auto', + }, }); // Divider @@ -479,6 +524,9 @@ export const useExploreAdditionalActionsMenu = ( canDownloadCSV, copyLink, dashboards, + dashboardMenuItems, + dashboardSearchTerm, + debouncedDashboardSearchTerm, datasource, dispatch, exportCSV, @@ -490,6 +538,7 @@ export const useExploreAdditionalActionsMenu = ( onOpenPropertiesModal, reportMenuItem, shareByEmail, + showDashboardSearch, slice, theme.sizeUnit, ]); diff --git a/superset-frontend/src/features/datasets/AddDataset/Footer/index.tsx b/superset-frontend/src/features/datasets/AddDataset/Footer/index.tsx index 1d81c8af4db..0688836de75 100644 --- a/superset-frontend/src/features/datasets/AddDataset/Footer/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/Footer/index.tsx @@ -129,11 +129,15 @@ function Footer({ datasets?.includes(datasetObject?.table_name); const dropdownMenu = ( - - - {CREATE_DATASET_ONLY_TEXT} - - + ); return ( diff --git a/superset-frontend/src/features/datasets/AddDataset/Header/index.tsx b/superset-frontend/src/features/datasets/AddDataset/Header/index.tsx index f80b349e8b7..da17182a971 100644 --- a/superset-frontend/src/features/datasets/AddDataset/Header/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/Header/index.tsx @@ -54,10 +54,12 @@ const renderDisabledSaveButton = () => ( ); const renderOverlay = () => ( - - {t('Settings')} - {t('Delete')} - + ); export default function Header({ diff --git a/superset-frontend/src/features/home/LanguagePicker.stories.tsx b/superset-frontend/src/features/home/LanguagePicker.stories.tsx index aa131821ee6..3727bd5258e 100644 --- a/superset-frontend/src/features/home/LanguagePicker.stories.tsx +++ b/superset-frontend/src/features/home/LanguagePicker.stories.tsx @@ -16,8 +16,24 @@ * specific language governing permissions and limitations * under the License. */ -import { MainNav as Menu } from '@superset-ui/core/components/Menu'; // Ensure correct import path -import LanguagePicker from './LanguagePicker'; // Ensure correct import path +import { Menu } from '@superset-ui/core/components/Menu'; +import { useLanguageMenuItems } from './LanguagePicker'; +import type { Languages } from './LanguagePicker'; + +// Component to demonstrate the hook usage +const LanguagePicker = ({ + locale, + languages, +}: { + locale: string; + languages: Languages; +}) => { + const languageMenuItem = useLanguageMenuItems({ locale, languages }); + + return ( + + ); +}; export default { title: 'Components/LanguagePicker', @@ -48,11 +64,7 @@ const mockedProps = { }, }; -const Template = (args: any) => ( - - - -); +const Template = (args: any) => ; export const Default = Template.bind({}); Default.args = mockedProps; diff --git a/superset-frontend/src/features/home/LanguagePicker.test.tsx b/superset-frontend/src/features/home/LanguagePicker.test.tsx index 59dbeab0ac4..45ac2d47463 100644 --- a/superset-frontend/src/features/home/LanguagePicker.test.tsx +++ b/superset-frontend/src/features/home/LanguagePicker.test.tsx @@ -17,8 +17,8 @@ * under the License. */ import { render, screen, userEvent } from 'spec/helpers/testing-library'; -import { MainNav as Menu } from '@superset-ui/core/components/Menu'; -import LanguagePicker from './LanguagePicker'; +import { Menu } from '@superset-ui/core/components/Menu'; +import { useLanguageMenuItems } from './LanguagePicker'; const mockedProps = { locale: 'en', @@ -36,31 +36,33 @@ const mockedProps = { }, }; -test('should render', async () => { - const { container } = render( - - - , +const TestLanguagePicker = ({ locale, languages }: typeof mockedProps) => { + const languageMenuItem = useLanguageMenuItems({ locale, languages }); + + return ( + ); +}; + +test('should render', async () => { + const { container } = render(, { + useRouter: true, + }); expect(await screen.findByRole('menu')).toBeInTheDocument(); expect(container).toBeInTheDocument(); }); -test('should render the language picker', async () => { - render( - - - , - ); - expect(await screen.findByLabelText('Languages')).toBeInTheDocument(); +test('should render the language picker', () => { + render(, { + useRouter: true, + }); + expect(screen.getByRole('menu', { name: 'Languages' })).toBeInTheDocument(); }); test('should render the items', async () => { - render( - - - , - ); + render(, { + useRouter: true, + }); userEvent.hover(screen.getByRole('menuitem')); expect(await screen.findByText('English')).toBeInTheDocument(); expect(await screen.findByText('Italian')).toBeInTheDocument(); diff --git a/superset-frontend/src/features/home/LanguagePicker.tsx b/superset-frontend/src/features/home/LanguagePicker.tsx index 0dddff17d09..039f1e19489 100644 --- a/superset-frontend/src/features/home/LanguagePicker.tsx +++ b/superset-frontend/src/features/home/LanguagePicker.tsx @@ -16,12 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { MainNav as Menu } from '@superset-ui/core/components/Menu'; -import { styled, css, useTheme } from '@superset-ui/core'; +import { useMemo } from 'react'; +import { MenuItem } from '@superset-ui/core/components/Menu'; +import { styled, t } from '@superset-ui/core'; import { Icons } from '@superset-ui/core/components/Icons'; import { Typography } from '@superset-ui/core/components/Typography'; -const { SubMenu } = Menu; export interface Languages { [key: string]: { flag: string; @@ -51,44 +51,34 @@ const StyledLabel = styled.div` } `; -const StyledFlag = styled.i` - margin-top: 2px; -`; +export const useLanguageMenuItems = ({ + locale, + languages, +}: LanguagePickerProps): MenuItem => + useMemo(() => { + const items: MenuItem[] = Object.keys(languages).map(langKey => ({ + key: langKey, + label: ( + + + + {languages[langKey].name} + + + ), + style: { whiteSpace: 'normal', height: 'auto' }, + })); -export default function LanguagePicker(props: LanguagePickerProps) { - const { locale, languages, ...rest } = props; - const theme = useTheme(); - return ( - - - - } - icon={} - {...rest} - > - {Object.keys(languages).map(langKey => ( - - - - - {languages[langKey].name} - - - - ))} - - ); -} + return { + key: 'language-submenu', + type: 'submenu' as const, + label: ( + + + + ), + icon: , + children: items, + popupClassName: 'language-picker-popup', + }; + }, [languages, locale]); diff --git a/superset-frontend/src/features/home/RightMenu.tsx b/superset-frontend/src/features/home/RightMenu.tsx index c9573ab81d7..43c8e4afc17 100644 --- a/superset-frontend/src/features/home/RightMenu.tsx +++ b/superset-frontend/src/features/home/RightMenu.tsx @@ -16,12 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { Fragment, useState, useEffect, FC, PureComponent } from 'react'; +import { useState, useEffect, FC, PureComponent, useMemo } from 'react'; import rison from 'rison'; import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import { useQueryParams, BooleanParam } from 'use-query-params'; -import { get, isEmpty } from 'lodash'; +import { isEmpty } from 'lodash'; import { t, styled, @@ -32,14 +32,14 @@ import { useTheme, } from '@superset-ui/core'; import { - Label, + Tag, Tooltip, - ThemeSubMenu, Menu, Icons, Typography, TelemetryPixel, } from '@superset-ui/core/components'; +import type { MenuItem } from '@superset-ui/core/components/Menu'; import { ensureAppRoot } from 'src/utils/pathUtils'; import { findPermission } from 'src/utils/findPermission'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; @@ -53,7 +53,8 @@ import DatabaseModal from 'src/features/databases/DatabaseModal'; import UploadDataModal from 'src/features/databases/UploadDataModel'; import { uploadUserPerms } from 'src/views/CRUD/utils'; import { useThemeContext } from 'src/theme/ThemeProvider'; -import LanguagePicker from './LanguagePicker'; +import { useThemeMenuItems } from 'src/hooks/useThemeMenuItems'; +import { useLanguageMenuItems } from './LanguagePicker'; import { ExtensionConfigs, GlobalMenuDataOptions, @@ -70,10 +71,6 @@ const versionInfoStyles = (theme: SupersetTheme) => css` white-space: nowrap; `; -const styledDisabled = (theme: SupersetTheme) => css` - color: ${theme.colors.grayscale.light1}; -`; - const StyledDiv = styled.div<{ align: string }>` display: flex; height: 100%; @@ -95,31 +92,16 @@ const StyledAnchor = styled.a` padding-left: ${({ theme }) => theme.sizeUnit}px; `; -const tagStyles = (theme: SupersetTheme) => css` - color: ${theme.colors.grayscale.light5}; -`; - -const styledChildMenu = (theme: SupersetTheme) => css` - &:hover { - color: ${theme.colorPrimary} !important; - cursor: pointer !important; - } -`; - -const { SubMenu } = Menu; - -const StyledSubMenu = styled(SubMenu)` - ${({ theme }) => css` - [data-icon='caret-down'] { - color: ${theme.colorIcon}; - font-size: ${theme.fontSizeXS}px; - margin-left: ${theme.sizeUnit}px; - } - &.ant-menu-submenu-active { - .ant-menu-title-content { - color: ${theme.colorPrimary}; - } +const StyledMenuItem = styled.div<{ disabled?: boolean }>` + ${({ theme, disabled }) => css` + &&:hover { + color: ${!disabled && theme.colorPrimary}; + cursor: ${!disabled ? 'pointer' : 'not-allowed'}; } + ${disabled && + css` + color: ${theme.colors.grayscale.light1}; + `} `} `; @@ -325,25 +307,23 @@ const RightMenu = ({ "Enable 'Allow file uploads to database' in any database's settings", ); - const buildMenuItem = (item: MenuObjectChildProps) => - item.disable ? ( - + const buildMenuItem = (item: MenuObjectChildProps): MenuItem => ({ + key: item.name || item.label, + label: item.disable ? ( + {item.label} - + + ) : item.url ? ( + + {item.label} + ) : ( - - {item.url ? ( - - {' '} - {item.label}{' '} - - ) : ( - item.label - )} - - ); + item.label + ), + disabled: item.disable, + }); const onMenuOpen = (openKeys: string[]) => { // We should query the API only if opening Data submenus @@ -373,6 +353,261 @@ const RightMenu = ({ localStorage.removeItem('redux'); }; + // Use the theme menu hook + const themeMenuItem = useThemeMenuItems({ + setThemeMode, + themeMode, + hasLocalOverride: hasDevOverride(), + onClearLocalSettings: clearLocalOverrides, + allowOSPreference: canDetectOSPreference(), + }); + + const languageMenuItem = useLanguageMenuItems({ + locale: navbarRight.locale || 'en', + languages: navbarRight.languages || {}, + }); + + // Build main menu items + const menuItems = useMemo(() => { + // Build menu items for the new dropdown + const buildNewDropdownItems = (): MenuItem[] => { + const items: MenuItem[] = []; + + dropdownItems?.forEach(menu => { + const canShowChild = menu.childs?.some( + item => typeof item === 'object' && !!item.perm, + ); + + if (menu.childs) { + if (canShowChild) { + const childItems: MenuItem[] = []; + menu.childs.forEach((item, idx) => { + if (typeof item !== 'string' && item.name && item.perm) { + if (idx === 3) { + childItems.push({ type: 'divider', key: `divider-${idx}` }); + } + childItems.push(buildMenuItem(item)); + } + }); + + items.push({ + key: `sub2_${menu.label}`, + label: menu.label, + icon: menu.icon, + children: childItems, + }); + } else if (menu.url) { + if ( + findPermission(menu.perm as string, menu.view as string, roles) + ) { + items.push({ + key: menu.label, + label: isFrontendRoute(menu.url) ? ( + + {menu.icon} {menu.label} + + ) : ( + + {menu.icon} {menu.label} + + ), + }); + } + } + } else if ( + findPermission(menu.perm as string, menu.view as string, roles) + ) { + items.push({ + key: menu.label, + label: isFrontendRoute(menu.url) ? ( + + {menu.icon} {menu.label} + + ) : ( + + {menu.icon} {menu.label} + + ), + }); + } + }); + + return items; + }; + + // Build settings menu items + const buildSettingsMenuItems = (): MenuItem[] => { + const items: MenuItem[] = []; + + settings?.forEach((section, index) => { + const sectionItems: MenuItem[] = []; + + section.childs?.forEach(child => { + if (typeof child !== 'string') { + const menuItemDisplay = RightMenuItemIconExtension ? ( + + {child.label} + + + ) : ( + child.label + ); + + sectionItems.push({ + key: child.label, + label: isFrontendRoute(child.url) ? ( + {menuItemDisplay} + ) : ( + + {menuItemDisplay} + + ), + }); + } + }); + + items.push({ + type: 'group', + label: section.label, + key: section.label, + children: sectionItems, + }); + + if (index < settings.length - 1) { + items.push({ type: 'divider', key: `divider_${index}` }); + } + }); + + if (!navbarRight.user_is_anonymous) { + items.push({ type: 'divider', key: 'user-divider' }); + + const userItems: MenuItem[] = []; + if (navbarRight.user_info_url) { + userItems.push({ + key: 'info', + label: ( + + {t('Info')} + + ), + }); + } + userItems.push({ + key: 'logout', + label: ( + + {t('Logout')} + + ), + onClick: handleLogout, + }); + + items.push({ + type: 'group', + label: t('User'), + key: 'user-section', + children: userItems, + }); + } + + if (navbarRight.version_string || navbarRight.version_sha) { + items.push({ type: 'divider', key: 'version-info-divider' }); + + items.push({ + type: 'group', + label: t('About'), + key: 'about-section', + children: [ + { + key: 'about-info', + label: ( +
+ {navbarRight.show_watermark && ( +
+ {t('Powered by Apache Superset')} +
+ )} + {navbarRight.version_string && ( +
+ {t('Version')}: {navbarRight.version_string} +
+ )} + {navbarRight.version_sha && ( +
+ {t('SHA')}: {navbarRight.version_sha} +
+ )} + {navbarRight.build_number && ( +
+ {t('Build')}: {navbarRight.build_number} +
+ )} +
+ ), + }, + ], + }); + } + + return items; + }; + + const items: MenuItem[] = []; + + if (RightMenuExtension) { + items.push({ + key: 'extension', + label: , + }); + } + + if (!navbarRight.user_is_anonymous && showActionDropdown) { + items.push({ + key: 'new-dropdown', + label: ( + + ), + icon: , + children: buildNewDropdownItems(), + ...{ 'data-test': 'new-dropdown' }, + }); + } + + if (canSetMode()) { + items.push(themeMenuItem); + } + + if (navbarRight.show_language_picker && languageMenuItem) { + items.push(languageMenuItem); + } + + items.push({ + key: 'settings', + label: t('Settings'), + icon: , + children: buildSettingsMenuItems(), + }); + + return items; + }, [ + RightMenuExtension, + navbarRight, + showActionDropdown, + canSetMode, + theme.colorPrimary, + themeMenuItem, + languageMenuItem, + dropdownItems, + roles, + settings, + RightMenuItemIconExtension, + buildMenuItem, + handleLogout, + ]); + return ( {canDatabase && ( @@ -407,18 +642,32 @@ const RightMenu = ({ type="columnar" /> )} - {environmentTag?.text && ( - - )} + {environmentTag?.text && + (() => { + // Map color values to Ant Design semantic colors + const validAntDesignColors = [ + 'error', + 'warning', + 'success', + 'processing', + 'default', + ]; + + const tagColor = validAntDesignColors.includes(environmentTag.color) + ? environmentTag.color + : 'default'; + + return ( + + {environmentTag.text} + + ); + })()} - {RightMenuExtension && } - {!navbarRight.user_is_anonymous && showActionDropdown && ( - - } - icon={} - > - {dropdownItems?.map?.(menu => { - const canShowChild = menu.childs?.some( - item => typeof item === 'object' && !!item.perm, - ); - if (menu.childs) { - if (canShowChild) { - return ( - - {menu?.childs?.map?.((item, idx) => - typeof item !== 'string' && item.name && item.perm ? ( - - {idx === 3 && } - {buildMenuItem(item)} - - ) : null, - )} - - ); - } - if (!menu.url) { - return null; - } - } - return ( - findPermission( - menu.perm as string, - menu.view as string, - roles, - ) && ( - - {isFrontendRoute(menu.url) ? ( - - {menu.icon} {menu.label} - - ) : ( - - {menu.icon} {menu.label} - - )} - - ) - ); - })} - - )} - - {canSetMode() && ( - - )} - - } - > - {settings?.map?.((section, index) => [ - - {section?.childs?.map?.(child => { - if (typeof child !== 'string') { - const menuItemDisplay = RightMenuItemIconExtension ? ( - - {child.label} - - - ) : ( - child.label - ); - return ( - - {isFrontendRoute(child.url) ? ( - {menuItemDisplay} - ) : ( - - {menuItemDisplay} - - )} - - ); - } - return null; - })} - , - index < settings.length - 1 && ( - - ), - ])} - - {!navbarRight.user_is_anonymous && [ - , - - {navbarRight.user_info_url && ( - - - {t('Info')} - - - )} - - - {t('Logout')} - - - , - ]} - {(navbarRight.version_string || navbarRight.version_sha) && [ - , - -
- {navbarRight.show_watermark && ( -
- {t('Powered by Apache Superset')} -
- )} - {navbarRight.version_string && ( -
- {t('Version')}: {navbarRight.version_string} -
- )} - {navbarRight.version_sha && ( -
- {t('SHA')}: {navbarRight.version_sha} -
- )} - {navbarRight.build_number && ( -
- {t('Build')}: {navbarRight.build_number} -
- )} -
-
, - ]} -
- {navbarRight.show_language_picker && ( - - )} -
+ items={menuItems} + /> {navbarRight.documentation_url && ( <> ({ @@ -33,7 +33,12 @@ jest.mock('@superset-ui/core', () => ({ t: (key: string) => key, })); -describe('ThemeSubMenu', () => { +const TestComponent = (props: ThemeSubMenuProps) => { + const menuItem = useThemeMenuItems(props); + return ; +}; + +describe('useThemeMenuItems', () => { const defaultProps = { allowOSPreference: true, setThemeMode: jest.fn(), @@ -42,12 +47,8 @@ describe('ThemeSubMenu', () => { onClearLocalSettings: jest.fn(), }; - const renderThemeSubMenu = (props = defaultProps) => - render( - - - , - ); + const renderThemeMenu = (props = defaultProps) => + render(); const findMenuWithText = async (text: string) => { await waitFor(() => { @@ -66,7 +67,7 @@ describe('ThemeSubMenu', () => { }); it('renders Light and Dark theme options by default', async () => { - renderThemeSubMenu(); + renderThemeMenu(); userEvent.hover(await screen.findByRole('menuitem')); const menu = await findMenuWithText('Light'); @@ -76,7 +77,7 @@ describe('ThemeSubMenu', () => { }); it('does not render Match system option when allowOSPreference is false', async () => { - renderThemeSubMenu({ ...defaultProps, allowOSPreference: false }); + renderThemeMenu({ ...defaultProps, allowOSPreference: false }); userEvent.hover(await screen.findByRole('menuitem')); await waitFor(() => { @@ -85,7 +86,7 @@ describe('ThemeSubMenu', () => { }); it('renders with allowOSPreference as true by default', async () => { - renderThemeSubMenu(); + renderThemeMenu(); userEvent.hover(await screen.findByRole('menuitem')); const menu = await findMenuWithText('Match system'); @@ -95,7 +96,7 @@ describe('ThemeSubMenu', () => { it('renders clear option when both hasLocalOverride and onClearLocalSettings are provided', async () => { const mockClear = jest.fn(); - renderThemeSubMenu({ + renderThemeMenu({ ...defaultProps, hasLocalOverride: true, onClearLocalSettings: mockClear, @@ -109,7 +110,7 @@ describe('ThemeSubMenu', () => { it('does not render clear option when hasLocalOverride is false', async () => { const mockClear = jest.fn(); - renderThemeSubMenu({ + renderThemeMenu({ ...defaultProps, hasLocalOverride: false, onClearLocalSettings: mockClear, @@ -124,7 +125,7 @@ describe('ThemeSubMenu', () => { it('calls setThemeMode with DEFAULT when Light is clicked', async () => { const mockSet = jest.fn(); - renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet }); + renderThemeMenu({ ...defaultProps, setThemeMode: mockSet }); userEvent.hover(await screen.findByRole('menuitem')); const menu = await findMenuWithText('Light'); @@ -135,7 +136,7 @@ describe('ThemeSubMenu', () => { it('calls setThemeMode with DARK when Dark is clicked', async () => { const mockSet = jest.fn(); - renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet }); + renderThemeMenu({ ...defaultProps, setThemeMode: mockSet }); userEvent.hover(await screen.findByRole('menuitem')); const menu = await findMenuWithText('Dark'); @@ -146,7 +147,7 @@ describe('ThemeSubMenu', () => { it('calls setThemeMode with SYSTEM when Match system is clicked', async () => { const mockSet = jest.fn(); - renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet }); + renderThemeMenu({ ...defaultProps, setThemeMode: mockSet }); userEvent.hover(await screen.findByRole('menuitem')); const menu = await findMenuWithText('Match system'); @@ -157,7 +158,7 @@ describe('ThemeSubMenu', () => { it('calls onClearLocalSettings when Clear local theme is clicked', async () => { const mockClear = jest.fn(); - renderThemeSubMenu({ + renderThemeMenu({ ...defaultProps, hasLocalOverride: true, onClearLocalSettings: mockClear, @@ -171,27 +172,27 @@ describe('ThemeSubMenu', () => { }); it('displays sun icon for DEFAULT theme', () => { - renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DEFAULT }); + renderThemeMenu({ ...defaultProps, themeMode: ThemeMode.DEFAULT }); expect(screen.getByTestId('sun')).toBeInTheDocument(); }); it('displays moon icon for DARK theme', () => { - renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DARK }); + renderThemeMenu({ ...defaultProps, themeMode: ThemeMode.DARK }); expect(screen.getByTestId('moon')).toBeInTheDocument(); }); it('displays format-painter icon for SYSTEM theme', () => { - renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.SYSTEM }); + renderThemeMenu({ ...defaultProps, themeMode: ThemeMode.SYSTEM }); expect(screen.getByTestId('format-painter')).toBeInTheDocument(); }); it('displays override icon when hasLocalOverride is true', () => { - renderThemeSubMenu({ ...defaultProps, hasLocalOverride: true }); + renderThemeMenu({ ...defaultProps, hasLocalOverride: true }); expect(screen.getByTestId('format-painter')).toBeInTheDocument(); }); it('renders Theme group header', async () => { - renderThemeSubMenu(); + renderThemeMenu(); userEvent.hover(await screen.findByRole('menuitem')); const menu = await findMenuWithText('Theme'); @@ -200,7 +201,7 @@ describe('ThemeSubMenu', () => { }); it('renders sun icon for Light theme option', async () => { - renderThemeSubMenu(); + renderThemeMenu(); userEvent.hover(await screen.findByRole('menuitem')); const menu = await findMenuWithText('Light'); @@ -210,7 +211,7 @@ describe('ThemeSubMenu', () => { }); it('renders moon icon for Dark theme option', async () => { - renderThemeSubMenu(); + renderThemeMenu(); userEvent.hover(await screen.findByRole('menuitem')); const menu = await findMenuWithText('Dark'); @@ -220,7 +221,7 @@ describe('ThemeSubMenu', () => { }); it('renders format-painter icon for Match system option', async () => { - renderThemeSubMenu({ ...defaultProps, allowOSPreference: true }); + renderThemeMenu({ ...defaultProps, allowOSPreference: true }); userEvent.hover(await screen.findByRole('menuitem')); const menu = await findMenuWithText('Match system'); @@ -232,7 +233,7 @@ describe('ThemeSubMenu', () => { }); it('renders clear icon for Clear local theme option', async () => { - renderThemeSubMenu({ + renderThemeMenu({ ...defaultProps, hasLocalOverride: true, onClearLocalSettings: jest.fn(), @@ -248,7 +249,7 @@ describe('ThemeSubMenu', () => { }); it('renders divider before clear option when clear option is present', async () => { - renderThemeSubMenu({ + renderThemeMenu({ ...defaultProps, hasLocalOverride: true, onClearLocalSettings: jest.fn(), @@ -263,7 +264,7 @@ describe('ThemeSubMenu', () => { }); it('does not render divider when clear option is not present', async () => { - renderThemeSubMenu({ ...defaultProps }); + renderThemeMenu({ ...defaultProps }); userEvent.hover(await screen.findByRole('menuitem')); const divider = document.querySelector('.ant-menu-item-divider'); diff --git a/superset-frontend/packages/superset-ui-core/src/components/ThemeSubMenu/index.tsx b/superset-frontend/src/hooks/useThemeMenuItems.tsx similarity index 53% rename from superset-frontend/packages/superset-ui-core/src/components/ThemeSubMenu/index.tsx rename to superset-frontend/src/hooks/useThemeMenuItems.tsx index 2dbb36104bd..67e4411929c 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ThemeSubMenu/index.tsx +++ b/superset-frontend/src/hooks/useThemeMenuItems.tsx @@ -17,44 +17,9 @@ * under the License. */ import { useMemo } from 'react'; -import { Icons, Menu } from '@superset-ui/core/components'; -import { - css, - styled, - t, - ThemeMode, - useTheme, - ThemeAlgorithm, -} from '@superset-ui/core'; - -const StyledThemeSubMenu = styled(Menu.SubMenu)` - ${({ theme }) => css` - [data-icon='caret-down'] { - color: ${theme.colorIcon}; - font-size: ${theme.fontSizeXS}px; - margin-left: ${theme.sizeUnit}px; - } - &.ant-menu-submenu-active { - .ant-menu-title-content { - color: ${theme.colorPrimary}; - } - } - `} -`; - -const StyledThemeSubMenuItem = styled(Menu.Item)<{ selected: boolean }>` - ${({ theme, selected }) => css` - &:hover { - color: ${theme.colorPrimary} !important; - cursor: pointer !important; - } - ${selected && - css` - background-color: ${theme.colors.primary.light4} !important; - color: ${theme.colors.primary.dark1} !important; - `} - `} -`; +import { Icons } from '@superset-ui/core/components'; +import type { MenuItem } from '@superset-ui/core/components/Menu'; +import { t, ThemeMode, useTheme, ThemeAlgorithm } from '@superset-ui/core'; export interface ThemeSubMenuOption { key: ThemeMode; @@ -71,13 +36,13 @@ export interface ThemeSubMenuProps { allowOSPreference?: boolean; } -export const ThemeSubMenu: React.FC = ({ +export const useThemeMenuItems = ({ setThemeMode, themeMode, hasLocalOverride = false, onClearLocalSettings, allowOSPreference = true, -}: ThemeSubMenuProps) => { +}: ThemeSubMenuProps): MenuItem => { const theme = useTheme(); const handleSelect = (mode: ThemeMode) => { @@ -107,64 +72,70 @@ export const ThemeSubMenu: React.FC = ({ [hasLocalOverride, theme.colors.error.base, themeIconMap, themeMode], ); - const themeOptions: ThemeSubMenuOption[] = [ + const themeOptions: MenuItem[] = [ { key: ThemeMode.DEFAULT, - label: t('Light'), - icon: , + label: ( + <> + {t('Light')} + + ), onClick: () => handleSelect(ThemeMode.DEFAULT), }, { key: ThemeMode.DARK, - label: t('Dark'), - icon: , + label: ( + <> + {t('Dark')} + + ), onClick: () => handleSelect(ThemeMode.DARK), }, ...(allowOSPreference ? [ { key: ThemeMode.SYSTEM, - label: t('Match system'), - icon: , + label: ( + <> + {t('Match system')} + + ), onClick: () => handleSelect(ThemeMode.SYSTEM), }, ] : []), ]; - // Add clear settings option only when there's a local theme active - const clearOption = - onClearLocalSettings && hasLocalOverride - ? { - key: 'clear-local', - label: t('Clear local theme'), - icon: , - onClick: onClearLocalSettings, - } - : null; + const children: MenuItem[] = [ + { + type: 'group' as const, + label: t('Theme'), + key: 'theme-group', + children: themeOptions, + }, + ]; - return ( - } - > - - {themeOptions.map(option => ( - - {option.icon} {option.label} - - ))} - {clearOption && [ - , - - {clearOption.icon} {clearOption.label} - , - ]} - - ); + // Add clear settings option only when there's a local theme active + if (onClearLocalSettings && hasLocalOverride) { + children.push({ + type: 'divider' as const, + key: 'theme-divider', + }); + children.push({ + key: 'clear-local', + label: ( + <> + {t('Clear local theme')} + + ), + onClick: onClearLocalSettings, + }); + } + + return { + key: 'theme-sub-menu', + label: selectedThemeModeIcon, + icon: , + children, + }; }; diff --git a/superset/config.py b/superset/config.py index 7b33423acd4..27782c1a75c 100644 --- a/superset/config.py +++ b/superset/config.py @@ -2074,16 +2074,16 @@ ZIPPED_FILE_MAX_SIZE = 100 * 1024 * 1024 # 100MB ZIP_FILE_MAX_COMPRESS_RATIO = 200.0 # Configuration for environment tag shown on the navbar. Setting 'text' to '' will hide the tag. # noqa: E501 -# 'color' can either be a hex color code, or a dot-indexed theme color (e.g. error.base) +# 'color' support only Ant Design semantic colors (e.g., 'error', 'warning', 'success', 'processing', 'default) # noqa: E501 ENVIRONMENT_TAG_CONFIG = { "variable": "SUPERSET_ENV", "values": { "debug": { - "color": "error.base", + "color": "error", "text": "flask-debug", }, "development": { - "color": "error.base", + "color": "error", "text": "Development", }, "production": {