diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx index 624316280ca..e531db67eff 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx @@ -273,9 +273,7 @@ function Echart( ); const notMerge = !isDashboardRefreshing; - if (!notMerge) { - chartRef.current?.dispatchAction({ type: 'hideTip' }); - } + chartRef.current?.dispatchAction({ type: 'hideTip' }); chartRef.current?.setOption(themedEchartOptions, { notMerge, replaceMerge: notMerge ? undefined : ['series'], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/tooltip.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/tooltip.ts index f3de48b16e9..c940f5fea0c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/tooltip.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/tooltip.ts @@ -29,7 +29,8 @@ import { Refs } from '../types'; export function getDefaultTooltip(refs: Refs) { return { - appendToBody: true, + appendToBody: + typeof document !== 'undefined' ? !document.fullscreenElement : true, borderColor: 'transparent', // CSS hack applied on this class to resolve https://github.com/apache/superset/issues/30058 className: 'echarts-tooltip', diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index be29e3c8e89..c3c1add10fd 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -19,6 +19,7 @@ import { forwardRef, ReactNode, + RefObject, useContext, useEffect, useRef, @@ -61,6 +62,7 @@ type SliceHeaderProps = SliceHeaderControlsProps & { height: number; queriedDttm?: string | null; exportPivotExcel?: (arg0: string) => void; + chartHolderRef?: RefObject; }; const annotationsLoading = t('Annotation layers are still loading.'); @@ -171,6 +173,7 @@ const SliceHeader = forwardRef( width, height, exportPivotExcel = () => ({}), + chartHolderRef, }, ref, ) => { @@ -374,6 +377,7 @@ const SliceHeader = forwardRef( exploreUrl={exploreUrl} crossFiltersEnabled={isCrossFiltersEnabled} exportPivotExcel={exportPivotExcel} + chartHolderRef={chartHolderRef} /> )} diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx index b80d59f3f58..bc3adeb1a33 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx @@ -17,7 +17,12 @@ * under the License. */ -import { render, screen, userEvent } from 'spec/helpers/testing-library'; +import { + render, + screen, + userEvent, + waitFor, +} from 'spec/helpers/testing-library'; import { FeatureFlag, VizType } from '@superset-ui/core'; import mockState from 'spec/fixtures/mockState'; import { cachedSupersetGet } from 'src/utils/cachedSupersetGet'; @@ -122,6 +127,13 @@ const openMenu = () => { userEvent.click(screen.getByRole('button', { name: 'More Options' })); }; +const mockFullscreenElement = (getElement: () => Element | null) => { + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: getElement, + }); +}; + beforeEach(() => { mockCachedSupersetGet.mockClear(); mockCachedSupersetGet.mockResolvedValue({ @@ -135,6 +147,10 @@ beforeEach(() => { }); }); +afterEach(() => { + Reflect.deleteProperty(document, 'fullscreenElement'); +}); + test('Should render', () => { renderWrapper(); openMenu(); @@ -304,14 +320,61 @@ test('Should "Force refresh"', () => { expect(props.addSuccessToast).toHaveBeenCalledTimes(1); }); -test('Should "Enter fullscreen"', () => { - const props = createProps(); +test('Should sync local state after entering fullscreen', async () => { + const mockDiv = document.createElement('div'); + let fullscreenElement: Element | null = null; + mockFullscreenElement(() => fullscreenElement); + mockDiv.requestFullscreen = jest.fn().mockImplementation(async () => { + fullscreenElement = mockDiv; + }); + const originalExitFullscreen = document.exitFullscreen; + (document as any).exitFullscreen = jest.fn().mockResolvedValue(undefined); + const props = { + ...createProps(), + chartHolderRef: { current: mockDiv }, + }; renderWrapper(props); openMenu(); - expect(props.handleToggleFullSize).toHaveBeenCalledTimes(0); - userEvent.click(screen.getByText('Enter fullscreen')); - expect(props.handleToggleFullSize).toHaveBeenCalledTimes(1); + const fullscreenItem = screen.getByRole('menuitem', { + name: /enter fullscreen/i, + }); + await userEvent.click(fullscreenItem); + expect(props.handleToggleFullSize).toHaveBeenCalledTimes(0); + expect(mockDiv.requestFullscreen).toHaveBeenCalled(); + document.dispatchEvent(new Event('fullscreenchange')); + await waitFor(() => { + expect(props.handleToggleFullSize).toHaveBeenCalledTimes(1); + }); + (document as any).exitFullscreen = originalExitFullscreen; +}); + +test('Should sync local state after exiting fullscreen', async () => { + const mockDiv = document.createElement('div'); + let fullscreenElement: Element | null = mockDiv; + mockFullscreenElement(() => fullscreenElement); + const originalExitFullscreen = document.exitFullscreen; + (document as any).exitFullscreen = jest.fn().mockImplementation(async () => { + fullscreenElement = null; + }); + const props = { + ...createProps(), + isFullSize: true, + chartHolderRef: { current: mockDiv }, + }; + renderWrapper(props); + openMenu(); + const fullscreenItem = screen.getByRole('menuitem', { + name: /exit fullscreen/i, + }); + await userEvent.click(fullscreenItem); + expect(props.handleToggleFullSize).toHaveBeenCalledTimes(0); + expect(document.exitFullscreen).toHaveBeenCalledTimes(1); + document.dispatchEvent(new Event('fullscreenchange')); + await waitFor(() => { + expect(props.handleToggleFullSize).toHaveBeenCalledTimes(1); + }); + (document as any).exitFullscreen = originalExitFullscreen; }); test('Drill to detail modal is under featureflag', () => { diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/Styles.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/Styles.tsx new file mode 100644 index 00000000000..27d08fc86b6 --- /dev/null +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/Styles.tsx @@ -0,0 +1,71 @@ +/** + * 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 { css, SupersetTheme } from '@apache-superset/core/theme'; + +export const fullscreenStyles = (theme: SupersetTheme) => css` + [data-test='dashboard-component-chart-holder']:fullscreen { + background-color: ${theme.colorBgBase}; + width: 100vw; + height: 100vh; + box-sizing: border-box; + display: flex; + flex-direction: column; + padding: ${theme.sizeUnit * 4}px; + overflow: visible; + position: relative; + pointer-events: auto; + z-index: ${theme.zIndexPopupBase}; + opacity: 1; + visibility: visible; + + /* Ensure children take up available space */ + .dashboard-chart, + .chart-container, + .slice_container, + .chart-slice { + flex: 1 1 auto; + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + overflow: visible; + } + + /* Portaled components inside the fullscreen layer */ + .ant-dropdown, + .ant-tooltip, + .ant-modal-root, + .ant-select-dropdown, + .ant-popover { + z-index: ${theme.zIndexPopupBase + 1}; + pointer-events: auto; + } + } + + /* Interaction and Header fixes */ + [data-test='dashboard-component-chart-holder']:fullscreen * { + pointer-events: auto; + } + + [data-test='dashboard-component-chart-holder']:fullscreen + [data-test='slice-header'] { + z-index: ${theme.zIndexPopupBase}; + position: relative; + } +`; diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 9fcf2ab0096..557e6e31b6f 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -22,6 +22,7 @@ import { KeyboardEvent, useState, useRef, + useEffect, RefObject, } from 'react'; @@ -61,6 +62,8 @@ import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal'; import { ViewResultsModalTrigger } from './ViewResultsModalTrigger'; +import { Global } from '@emotion/react'; +import { fullscreenStyles } from './Styles'; const RefreshTooltip = styled.div` ${({ theme }) => css` @@ -96,6 +99,7 @@ const VerticalDotsTrigger = () => { }; export interface SliceHeaderControlsProps { + chartHolderRef?: RefObject; slice: { description: string; viz_type: string; @@ -150,6 +154,12 @@ const dropdownIconsStyles = css` } `; +const queueChartResize = () => { + window.setTimeout(() => { + window.dispatchEvent(new Event('resize')); + }, 300); +}; + const SliceHeaderControls = ( props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps, ) => { @@ -197,6 +207,41 @@ const SliceHeaderControls = ( } }; + const requestChartFullscreen = () => { + const chartHolder = props.chartHolderRef?.current; + + if (!chartHolder?.requestFullscreen) { + props.addDangerToast(t('Fullscreen is not supported in this browser.')); + return; + } + + chartHolder.requestFullscreen().catch(error => { + props.addDangerToast( + t( + 'Error enabling fullscreen: %s', + error instanceof Error ? error.message : t('Unknown error'), + ), + ); + }); + }; + + const exitChartFullscreen = () => { + if (!document.exitFullscreen) { + props.handleToggleFullSize(); + queueChartResize(); + return; + } + + document.exitFullscreen().catch(error => { + props.addDangerToast( + t( + 'Error disabling fullscreen: %s', + error instanceof Error ? error.message : t('Unknown error'), + ), + ); + }); + }; + const handleMenuClick = ({ key, domEvent, @@ -231,9 +276,19 @@ const SliceHeaderControls = ( // eslint-disable-next-line no-unused-expressions props.exportPivotCSV?.(props.slice.slice_id); break; - case MenuKeys.Fullscreen: - props.handleToggleFullSize(); + case MenuKeys.Fullscreen: { + if (props.isFullSize) { + if (document.fullscreenElement) { + exitChartFullscreen(); + } else { + props.handleToggleFullSize(); + queueChartResize(); + } + } else { + requestChartFullscreen(); + } break; + } case MenuKeys.ExportFullCsv: // eslint-disable-next-line no-unused-expressions props.exportFullCSV?.(props.slice.slice_id); @@ -351,9 +406,9 @@ const SliceHeaderControls = ( ? t('Exit fullscreen') : t('Enter fullscreen'); - // @z-index-below-dashboard-header (100) - 1 = 99 for !isFullSize and 101 for isFullSize + // Use theme.zIndexPopupBase to keep dropdown above fullscreen (+1) or below dashboard header (-1) const dropdownOverlayStyle = { - zIndex: isFullSize ? 101 : 99, + zIndex: isFullSize ? theme.zIndexPopupBase + 1 : theme.zIndexPopupBase - 1, animationDuration: '0s', }; @@ -554,13 +609,35 @@ const SliceHeaderControls = ( }); } + useEffect(() => { + const handleFullscreenChange = () => { + const isChartFullscreen = + document.fullscreenElement === props.chartHolderRef?.current; + + if (isChartFullscreen !== Boolean(isFullSize)) { + props.handleToggleFullSize(); + queueChartResize(); + } + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange); + }; + }, [isFullSize, props.chartHolderRef, props.handleToggleFullSize]); + return ( <> {isFullSize && ( { - props.handleToggleFullSize(); + if (document.fullscreenElement) { + exitChartFullscreen(); + } else { + props.handleToggleFullSize(); + queueChartResize(); + } }} /> )} @@ -603,8 +680,8 @@ const SliceHeaderControls = ( showModal={drillModalIsOpen} dataset={datasetWithVerboseMap} /> - {canEditCrossFilters && scopingModal} + {isFullSize && } ); }; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx index 3c6c136159c..4a707a3522c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx @@ -17,7 +17,15 @@ * under the License. */ import cx from 'classnames'; -import { useCallback, useEffect, useRef, useMemo, useState, memo } from 'react'; +import { + useCallback, + useEffect, + useRef, + useMemo, + useState, + memo, + RefObject, +} from 'react'; import type { ChartCustomization, JsonObject } from '@superset-ui/core'; import { styled } from '@apache-superset/core/theme'; import { t } from '@apache-superset/core/translation'; @@ -88,6 +96,7 @@ interface ChartProps { extraControls?: JsonObject; isInView?: boolean; cacheBusterProp?: string | number; + chartHolderRef?: RefObject; } const RESIZE_TIMEOUT = 500; @@ -688,6 +697,7 @@ const Chart = (props: ChartProps) => { width={width} height={getHeaderHeight()} exportPivotExcel={exportPivotExcel as unknown as (arg0: string) => void} + chartHolderRef={props.chartHolderRef} /> {/* diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder/ChartHolder.tsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder/ChartHolder.tsx index 597f8887796..7f2658a9667 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder/ChartHolder.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder/ChartHolder.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { useState, useMemo, useCallback, useEffect, memo } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'; import { ResizeCallback, ResizeStartCallback } from 're-resizable'; import cx from 'classnames'; @@ -31,6 +31,7 @@ import HoverMenu from 'src/dashboard/components/menu/HoverMenu'; import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer'; import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath'; import useFilterFocusHighlightStyles from 'src/dashboard/util/useFilterFocusHighlightStyles'; +import { AntdThemeProvider } from '@superset-ui/core/components'; import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes'; import { GRID_BASE_UNIT, @@ -105,6 +106,7 @@ const ChartHolder = ({ `; const { chartId } = component.meta; const isFullSize = fullSizeChartId === chartId; + const chartHolderRef = useRef(null); const focusHighlightStyles = useFilterFocusHighlightStyles(chartId ?? 0); const directPathToChild = useSelector( @@ -257,7 +259,19 @@ const ChartHolder = ({ editMode={editMode} >
{ + if (typeof dragSourceRef === 'function') { + dragSourceRef(el); + } else if ( + dragSourceRef && + Object.prototype.hasOwnProperty.call(dragSourceRef, 'current') + ) { + ( + dragSourceRef as React.MutableRefObject + ).current = el; + } + chartHolderRef.current = el; + }} data-test="dashboard-component-chart-holder" style={focusHighlightStyles} css={isFullSize ? fullSizeStyle : undefined} @@ -269,46 +283,59 @@ const ChartHolder = ({ outlinedComponentId ? 'fade-in' : 'fade-out', )} > - {!editMode && ( - + document.fullscreenElement + ? (triggerNode?.closest?.( + '[data-test="dashboard-component-chart-holder"]', + ) as HTMLElement) || document.body + : document.body + } + > + {!editMode && ( + + )} + {!!outlinedComponentId && ( + + )} + + handleUpdateSliceName(name) + } + isComponentVisible={isComponentVisible} + handleToggleFullSize={handleToggleFullSize} + isFullSize={isFullSize} + setControlValue={handleExtraControl} + extraControls={extraControls} + isInView={isInView} + chartHolderRef={chartHolderRef} /> - )} - {!!outlinedComponentId && ( - - )} - - handleUpdateSliceName(name) - } - isComponentVisible={isComponentVisible} - handleToggleFullSize={handleToggleFullSize} - isFullSize={isFullSize} - setControlValue={handleExtraControl} - extraControls={extraControls} - isInView={isInView} - /> - {editMode && ( - -
- -
-
- )} + {editMode && ( + +
+ +
+
+ )} +
),