diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx index 87011ae4c73c..c9240a4526ac 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Fragment, useCallback, memo } from 'react'; +import { Fragment, useCallback, memo, useEffect } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; @@ -24,7 +24,8 @@ import { t } from '@superset-ui/core'; import { styled } from '@apache-superset/core/ui'; import { EditableTitle, EmptyState } from '@superset-ui/core/components'; -import { setEditMode } from 'src/dashboard/actions/dashboardState'; +import { setEditMode, onRefresh } from 'src/dashboard/actions/dashboardState'; +import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponent'; import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; import AnchorLink from 'src/dashboard/components/AnchorLink'; import { @@ -37,6 +38,9 @@ import { TAB_TYPE } from 'src/dashboard/util/componentTypes'; export const RENDER_TAB = 'RENDER_TAB'; export const RENDER_TAB_CONTENT = 'RENDER_TAB_CONTENT'; +// Delay before refreshing charts to ensure they are fully mounted +const CHART_MOUNT_DELAY = 100; + const propTypes = { dashboardId: PropTypes.number.isRequired, id: PropTypes.string.isRequired, @@ -64,6 +68,7 @@ const propTypes = { handleComponentDrop: PropTypes.func.isRequired, updateComponents: PropTypes.func.isRequired, setDirectPathToChild: PropTypes.func.isRequired, + isComponentVisible: PropTypes.bool, }; const defaultProps = { @@ -114,6 +119,43 @@ const renderDraggableContent = dropProps => const Tab = props => { const dispatch = useDispatch(); const canEdit = useSelector(state => state.dashboardInfo.dash_edit_perm); + const dashboardLayout = useSelector(state => state.dashboardLayout.present); + const lastRefreshTime = useSelector( + state => state.dashboardState.lastRefreshTime, + ); + const tabActivationTime = useSelector( + state => state.dashboardState.tabActivationTimes?.[props.id] || 0, + ); + const dashboardInfo = useSelector(state => state.dashboardInfo); + + useEffect(() => { + if (props.renderType === RENDER_TAB_CONTENT && props.isComponentVisible) { + if ( + lastRefreshTime && + tabActivationTime && + lastRefreshTime > tabActivationTime + ) { + const chartIds = getChartIdsFromComponent(props.id, dashboardLayout); + if (chartIds.length > 0) { + requestAnimationFrame(() => { + setTimeout(() => { + dispatch(onRefresh(chartIds, true, 0, dashboardInfo.id)); + }, CHART_MOUNT_DELAY); + }); + } + } + } + }, [ + props.isComponentVisible, + props.renderType, + props.id, + lastRefreshTime, + tabActivationTime, + dashboardLayout, + dashboardInfo.id, + dispatch, + ]); + const handleChangeTab = useCallback( ({ pathToTabIndex }) => { props.setDirectPathToChild(pathToTabIndex); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx index 3ff5f2879783..a5072293ada2 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx @@ -26,11 +26,15 @@ import { } from 'spec/helpers/testing-library'; import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; import { EditableTitle } from '@superset-ui/core/components'; -import { setEditMode } from 'src/dashboard/actions/dashboardState'; +import { setEditMode, onRefresh } from 'src/dashboard/actions/dashboardState'; import Tab from './Tab'; import Markdown from '../Markdown'; +jest.mock('src/dashboard/util/getChartIdsFromComponent', () => + jest.fn(() => []), +); + jest.mock('src/dashboard/containers/DashboardComponent', () => jest.fn(() =>
), ); @@ -66,6 +70,9 @@ jest.mock('src/dashboard/actions/dashboardState', () => ({ setEditMode: jest.fn(() => ({ type: 'SET_EDIT_MODE', })), + onRefresh: jest.fn(() => ({ + type: 'ON_REFRESH', + })), })); const createProps = () => ({ @@ -445,3 +452,91 @@ test('AnchorLink does not render in embedded mode', () => { expect(screen.queryByTestId('anchor-link')).not.toBeInTheDocument(); }); + +test('Should refresh charts when tab becomes active after dashboard refresh', async () => { + jest.clearAllMocks(); + const getChartIdsFromComponent = require('src/dashboard/util/getChartIdsFromComponent'); + getChartIdsFromComponent.mockReturnValue([101, 102]); + + const props = createProps(); + props.renderType = 'RENDER_TAB_CONTENT'; + props.isComponentVisible = false; + + const initialState = { + dashboardState: { + lastRefreshTime: Date.now() - 5000, // Dashboard was refreshed 5 seconds ago + tabActivationTimes: { + 'TAB-YT6eNksV-': Date.now() - 10000, // Tab was activated 10 seconds ago (before refresh) + }, + }, + dashboardInfo: { + id: 23, + dash_edit_perm: true, + }, + }; + + const { rerender } = render(, { + useRedux: true, + useDnd: true, + initialState, + }); + + // onRefresh should not be called when tab is not visible + expect(onRefresh).not.toHaveBeenCalled(); + + // Make tab visible - this should trigger refresh since lastRefreshTime > tabActivationTime + rerender(); + + // Wait for the refresh to be triggered after the delay + await waitFor( + () => { + expect(onRefresh).toHaveBeenCalled(); + }, + { timeout: 500 }, + ); + + expect(onRefresh).toHaveBeenCalledWith( + [101, 102], // Chart IDs from the tab + true, // Force refresh + 0, // Interval + 23, // Dashboard ID + ); +}); + +test('Should not refresh charts when tab becomes active if no dashboard refresh occurred', async () => { + jest.clearAllMocks(); + const getChartIdsFromComponent = require('src/dashboard/util/getChartIdsFromComponent'); + getChartIdsFromComponent.mockReturnValue([101]); + + const props = createProps(); + props.renderType = 'RENDER_TAB_CONTENT'; + props.isComponentVisible = false; + + const currentTime = Date.now(); + const initialState = { + dashboardState: { + lastRefreshTime: currentTime - 10000, // Dashboard was refreshed 10 seconds ago + tabActivationTimes: { + 'TAB-YT6eNksV-': currentTime - 5000, // Tab was activated 5 seconds ago (after refresh) + }, + }, + dashboardInfo: { + id: 23, + dash_edit_perm: true, + }, + }; + + const { rerender } = render(, { + useRedux: true, + useDnd: true, + initialState, + }); + + // Make tab visible - should NOT trigger refresh since tabActivationTime > lastRefreshTime + rerender(); + + // Wait a bit to ensure no refresh is triggered + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(onRefresh).not.toHaveBeenCalled(); +}); diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.js b/superset-frontend/src/dashboard/reducers/dashboardState.js index 9336d5638826..e24f875022f6 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardState.js +++ b/superset-frontend/src/dashboard/reducers/dashboardState.js @@ -59,7 +59,17 @@ import { HYDRATE_DASHBOARD } from '../actions/hydrate'; export default function dashboardStateReducer(state = {}, action) { const actionHandlers = { [HYDRATE_DASHBOARD]() { - return { ...state, ...action.data.dashboardState }; + const hydratedState = { ...state, ...action.data.dashboardState }; + // Initialize tab activation times for initially active tabs + if (hydratedState.activeTabs && hydratedState.activeTabs.length > 0) { + const now = Date.now(); + hydratedState.tabActivationTimes = + hydratedState.tabActivationTimes || {}; + hydratedState.activeTabs.forEach(tabId => { + hydratedState.tabActivationTimes[tabId] = now; + }); + } + return hydratedState; }, [ADD_SLICE]() { const updatedSliceIds = new Set(state.sliceIds); @@ -181,6 +191,7 @@ export default function dashboardStateReducer(state = {}, action) { return { ...state, isRefreshing: true, + lastRefreshTime: Date.now(), }; }, [ON_FILTERS_REFRESH]() { @@ -216,10 +227,17 @@ export default function dashboardStateReducer(state = {}, action) { .difference(new Set(action.activeTabs)) .union(new Set(action.inactiveTabs)); + // Track when each tab was last activated + const tabActivationTimes = { ...state.tabActivationTimes }; + action.activeTabs.forEach(tabId => { + tabActivationTimes[tabId] = Date.now(); + }); + return { ...state, inactiveTabs: Array.from(newInactiveTabs), activeTabs: Array.from(newActiveTabs.union(new Set(action.activeTabs))), + tabActivationTimes, }; }, [SET_ACTIVE_TABS]() { diff --git a/superset-frontend/src/dashboard/util/charts/useAllChartIds.ts b/superset-frontend/src/dashboard/util/charts/useAllChartIds.ts new file mode 100644 index 000000000000..dc6a48fb4592 --- /dev/null +++ b/superset-frontend/src/dashboard/util/charts/useAllChartIds.ts @@ -0,0 +1,29 @@ +/** + * 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 { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { RootState } from 'src/dashboard/types'; +import getChartIdsFromLayout from '../getChartIdsFromLayout'; + +export const useAllChartIds = () => { + const layout = useSelector( + (state: RootState) => state.dashboardLayout.present, + ); + return useMemo(() => getChartIdsFromLayout(layout), [layout]); +}; diff --git a/superset-frontend/src/dashboard/util/getChartIdsFromComponent.ts b/superset-frontend/src/dashboard/util/getChartIdsFromComponent.ts new file mode 100644 index 000000000000..507550c76bc7 --- /dev/null +++ b/superset-frontend/src/dashboard/util/getChartIdsFromComponent.ts @@ -0,0 +1,44 @@ +/** + * 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 { CHART_TYPE } from './componentTypes'; +import type { DashboardLayout } from '../types'; + +export default function getChartIdsFromComponent( + componentId: string, + layout: DashboardLayout, +): number[] { + const chartIds: number[] = []; + const component = layout[componentId]; + + if (!component) return chartIds; + + // If this component is a chart, add its ID + if (component.type === CHART_TYPE && component.meta?.chartId) { + chartIds.push(component.meta.chartId); + } + + // Recursively check children + if (component.children) { + component.children.forEach((childId: string) => { + chartIds.push(...getChartIdsFromComponent(childId, layout)); + }); + } + + return chartIds; +}