diff --git a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/download_chart.test.js b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/download_chart.test.js index 029ead311..6f0643cc8 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/download_chart.test.js +++ b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/download_chart.test.js @@ -35,7 +35,7 @@ describe('Download Chart > Distribution bar chart', () => { cy.visitChartByParams(formData); cy.get('.header-with-actions .ant-dropdown-trigger').click(); - cy.get(':nth-child(1) > .ant-dropdown-menu-submenu-title').click(); + cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click(); cy.get( '.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)', ).click(); diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts index 2f0aeff01..6a8da3ffe 100644 --- a/superset-frontend/src/constants.ts +++ b/superset-frontend/src/constants.ts @@ -107,6 +107,10 @@ export const URL_PARAMS = { name: 'dashboard_page_id', type: 'string', }, + dashboardFocusedChart: { + name: 'focused_chart', + type: 'number', + }, } as const; export const RESERVED_CHART_URL_PARAMS: string[] = [ diff --git a/superset-frontend/src/dashboard/actions/hydrate.js b/superset-frontend/src/dashboard/actions/hydrate.js index 4422165a0..da16e2f6f 100644 --- a/superset-frontend/src/dashboard/actions/hydrate.js +++ b/superset-frontend/src/dashboard/actions/hydrate.js @@ -62,6 +62,7 @@ export const HYDRATE_DASHBOARD = 'HYDRATE_DASHBOARD'; export const hydrateDashboard = ({ + history, dashboard, charts, filterboxMigrationState = FILTER_BOX_MIGRATION_STATES.NOOP, @@ -291,8 +292,25 @@ export const hydrateDashboard = future: [], }; + // Searches for a focused_chart parameter in the URL to automatically focus a chart + const focusedChartId = getUrlParam(URL_PARAMS.dashboardFocusedChart); + let focusedChartLayoutId; + if (focusedChartId) { + // Converts focused_chart to dashboard layout id + const found = Object.values(dashboardLayout.present).find( + element => element.meta?.chartId === focusedChartId, + ); + focusedChartLayoutId = found?.id; + // Removes the focused_chart parameter from the URL + const params = new URLSearchParams(window.location.search); + params.delete(URL_PARAMS.dashboardFocusedChart.name); + history.replace({ + search: params.toString(), + }); + } + // find direct link component and path from root - const directLinkComponentId = getLocationHash(); + const directLinkComponentId = focusedChartLayoutId || getLocationHash(); let directPathToChild = dashboardState.directPathToChild || []; if (layout[directLinkComponentId]) { directPathToChild = (layout[directLinkComponentId].parents || []).slice(); diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index 17097b6c1..598ed3e6e 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -17,6 +17,7 @@ * under the License. */ import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { useHistory } from 'react-router-dom'; import { CategoricalColorNamespace, FeatureFlag, @@ -155,6 +156,7 @@ const useSyncDashboardStateWithLocalStorage = () => { export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { const dispatch = useDispatch(); const theme = useTheme(); + const history = useHistory(); const user = useSelector( state => state.user, ); @@ -301,6 +303,7 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { } dispatch( hydrateDashboard({ + history, dashboard, charts, activeTabs, diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx index 51e87ee20..525d02be5 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx @@ -24,6 +24,8 @@ import { Tooltip } from 'src/components/Tooltip'; import { CategoricalColorNamespace, css, + FeatureFlag, + isFeatureEnabled, logging, SupersetClient, t, @@ -149,6 +151,7 @@ export const ExploreChartHeader = ({ actions.redirectSQLLab, openPropertiesModal, ownState, + metadata?.dashboards, ); const metadataBar = useMemo(() => { @@ -162,6 +165,13 @@ export const ExploreChartHeader = ({ metadata.dashboards.length > 0 ? t('Added to %s dashboard(s)', metadata.dashboards.length) : t('Not added to any dashboard'), + description: + metadata.dashboards.length > 0 && + isFeatureEnabled(FeatureFlag.CROSS_REFERENCES) + ? t( + 'You can preview the list of dashboards on the chart settings dropdown.', + ) + : undefined, }); items.push({ type: MetadataType.LAST_MODIFIED, diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx new file mode 100644 index 000000000..aea0b4a8e --- /dev/null +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.test.tsx @@ -0,0 +1,78 @@ +/** + * 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 React from 'react'; +import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import { Menu } from 'src/components/Menu'; +import DashboardItems from './DashboardsSubMenu'; + +const asyncRender = (numberOfItems: number) => + waitFor(() => { + const dashboards = []; + for (let i = 1; i <= numberOfItems; i += 1) { + dashboards.push({ id: i, dashboard_title: `Dashboard ${i}` }); + } + render( + + + + + , + { + useRouter: true, + }, + ); + }); + +test('renders a submenu', async () => { + await asyncRender(3); + expect(screen.getByText('Dashboard 1')).toBeInTheDocument(); + expect(screen.getByText('Dashboard 2')).toBeInTheDocument(); + expect(screen.getByText('Dashboard 3')).toBeInTheDocument(); +}); + +test('renders a submenu with search', async () => { + await asyncRender(20); + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); +}); + +test('displays a searched value', async () => { + await asyncRender(20); + userEvent.type(screen.getByPlaceholderText('Search'), '2'); + expect(screen.getByText('Dashboard 2')).toBeInTheDocument(); + expect(screen.getByText('Dashboard 20')).toBeInTheDocument(); +}); + +test('renders a "No results found" message when searching', async () => { + await asyncRender(20); + userEvent.type(screen.getByPlaceholderText('Search'), 'unknown'); + expect(screen.getByText('No results found')).toBeInTheDocument(); +}); + +test('renders a submenu with no dashboards', async () => { + await asyncRender(0); + expect(screen.getByText('None')).toBeInTheDocument(); +}); + +test('shows link icon when hovering', async () => { + await asyncRender(3); + expect(screen.queryByRole('img', { name: 'full' })).not.toBeInTheDocument(); + userEvent.hover(screen.getByText('Dashboard 1')); + expect(screen.getByRole('img', { name: 'full' })).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx new file mode 100644 index 000000000..841f3124c --- /dev/null +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/DashboardsSubMenu.tsx @@ -0,0 +1,146 @@ +/** + * 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 React, { useState } from 'react'; +import { css, t, useTheme } from '@superset-ui/core'; +import { Input } from 'src/components/Input'; +import Icons from 'src/components/Icons'; +import { Menu } from 'src/components/Menu'; +import { Link } from 'react-router-dom'; + +export interface DashboardsSubMenuProps { + chartId?: number; + dashboards?: { id: number; dashboard_title: string }[]; +} + +const WIDTH = 220; +const HEIGHT = 300; +const SEARCH_THRESHOLD = 10; + +const DashboardsSubMenu = ({ + chartId, + dashboards = [], + ...menuProps +}: DashboardsSubMenuProps) => { + const theme = useTheme(); + const [dashboardSearch, setDashboardSearch] = useState(); + const [hoveredItem, setHoveredItem] = useState(); + const showSearch = dashboards.length > SEARCH_THRESHOLD; + const filteredDashboards = dashboards.filter( + dashboard => + !dashboardSearch || + dashboard.dashboard_title + .toLowerCase() + .includes(dashboardSearch.toLowerCase()), + ); + const noResults = dashboards.length === 0; + const noResultsFound = dashboardSearch && filteredDashboards.length === 0; + const urlQueryString = chartId ? `?focused_chart=${chartId}` : ''; + return ( + <> + {showSearch && ( + } + css={css` + width: ${WIDTH}px; + margin: ${theme.gridUnit * 2}px ${theme.gridUnit * 3}px; + `} + value={dashboardSearch} + onChange={e => setDashboardSearch(e.currentTarget.value?.trim())} + /> + )} +
+ {filteredDashboards.map(dashboard => ( + setHoveredItem(dashboard.id)} + onMouseLeave={() => { + if (hoveredItem === dashboard.id) { + setHoveredItem(null); + } + }} + {...menuProps} + > + +
+
+ {dashboard.dashboard_title} +
+ +
+ +
+ ))} + {noResultsFound && ( +
+ {t('No results found')} +
+ )} + {noResults && ( + + {t('None')} + + )} +
+ + ); +}; + +export default DashboardsSubMenu; diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx index 66878b6a8..da66c6095 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx @@ -32,9 +32,11 @@ import HeaderReportDropDown from 'src/components/ReportModal/HeaderReportDropdow import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import ViewQueryModal from '../controls/ViewQueryModal'; import EmbedCodeContent from '../EmbedCodeContent'; +import DashboardsSubMenu from './DashboardsSubMenu'; const MENU_KEYS = { EDIT_PROPERTIES: 'edit_properties', + DASHBOARDS_ADDED_TO: 'dashboards_added_to', DOWNLOAD_SUBMENU: 'download_submenu', EXPORT_TO_CSV: 'export_to_csv', EXPORT_TO_CSV_PIVOTED: 'export_to_csv_pivoted', @@ -97,6 +99,7 @@ export const useExploreAdditionalActionsMenu = ( onOpenInEditor, onOpenPropertiesModal, ownState, + dashboards, ) => { const theme = useTheme(); const { addDangerToast, addSuccessToast } = useToasts(); @@ -246,14 +249,25 @@ export const useExploreAdditionalActionsMenu = ( openKeys={openSubmenus} onOpenChange={setOpenSubmenus} > - {slice && ( - <> + <> + {slice && ( {t('Edit chart properties')} - - - )} + )} + {isFeatureEnabled(FeatureFlag.CROSS_REFERENCES) && ( + + + + )} + + {VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type) ? ( <> @@ -369,6 +383,7 @@ export const useExploreAdditionalActionsMenu = ( addDangerToast, canDownloadCSV, chart, + dashboards, handleMenuClick, isDropdownVisible, latestQueryFormData, diff --git a/tests/integration_tests/superset_test_config.py b/tests/integration_tests/superset_test_config.py index 8f1ef27f2..b93e5cc15 100644 --- a/tests/integration_tests/superset_test_config.py +++ b/tests/integration_tests/superset_test_config.py @@ -71,6 +71,7 @@ FEATURE_FLAGS = { "ALERT_REPORTS": True, "DASHBOARD_NATIVE_FILTERS": True, "DRILL_TO_DETAIL": True, + "CROSS_REFERENCES": True, } WEBDRIVER_BASEURL = "http://0.0.0.0:8081/"