From 0277ebc225889006d9b07c2eedc2caeafdc4e8f8 Mon Sep 17 00:00:00 2001 From: Diego Medina Date: Wed, 16 Mar 2022 22:46:52 -0400 Subject: [PATCH] fix: Popovers in Explore not attached to the fields they are triggered by (#19139) * fix: Popovers in Explore not attached to the fields they are triggered by * fix * PR comment * remove unused import --- .../src/components/Popover/index.tsx | 3 + .../controls/AnnotationLayerControl/index.jsx | 12 +- .../ControlPopover/ControlPopover.test.tsx | 126 ++++++++++++++++++ .../ControlPopover/ControlPopover.tsx | 118 ++++++++++++++++ .../DateFilterControl/DateFilterLabel.tsx | 4 +- .../ColumnSelectPopoverTrigger.tsx | 7 +- .../FilterBoxItemControl.test.jsx | 4 +- .../FilterBoxItemControl.test.tsx | 4 +- .../controls/FilterBoxItemControl/index.jsx | 7 +- .../AdhocFilterPopoverTrigger.test.tsx | 3 +- .../AdhocFilterPopoverTrigger/index.tsx | 7 +- .../MetricControl/AdhocMetricOption.test.jsx | 4 +- .../AdhocMetricPopoverTrigger.tsx | 6 +- .../TimeSeriesColumnControl/index.jsx | 7 +- 14 files changed, 277 insertions(+), 35 deletions(-) create mode 100644 superset-frontend/src/explore/components/controls/ControlPopover/ControlPopover.test.tsx create mode 100644 superset-frontend/src/explore/components/controls/ControlPopover/ControlPopover.tsx diff --git a/superset-frontend/src/components/Popover/index.tsx b/superset-frontend/src/components/Popover/index.tsx index 880e45791..bccc31c35 100644 --- a/superset-frontend/src/components/Popover/index.tsx +++ b/superset-frontend/src/components/Popover/index.tsx @@ -18,6 +18,9 @@ */ import { Popover } from 'antd'; +export { PopoverProps } from 'antd/lib/popover'; +export { TooltipPlacement } from 'antd/lib/tooltip'; + // Eventually Popover can be wrapped and customized in this file // for now we're just redirecting export default Popover; diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.jsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.jsx index aa3255374..06fb582b5 100644 --- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.jsx @@ -22,11 +22,11 @@ import { List } from 'src/components'; import { connect } from 'react-redux'; import { t, withTheme } from '@superset-ui/core'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; -import Popover from 'src/components/Popover'; import AsyncEsmComponent from 'src/components/AsyncEsmComponent'; import { getChartKey } from 'src/explore/exploreUtils'; import { runAnnotationQuery } from 'src/components/Chart/chartAction'; import CustomListItem from 'src/explore/components/controls/CustomListItem'; +import ControlPopover from '../ControlPopover/ControlPopover'; const AnnotationLayer = AsyncEsmComponent( () => import('./AnnotationLayer'), @@ -167,10 +167,9 @@ class AnnotationLayerControl extends React.PureComponent { const addedAnnotation = this.props.value[addedAnnotationIndex]; const annotations = this.props.value.map((anno, i) => ( - ({ '&:hover': { @@ -190,7 +189,7 @@ class AnnotationLayerControl extends React.PureComponent { {anno.name} {this.renderInfo(anno)} - + )); const addLayerPopoverKey = 'add'; @@ -198,9 +197,8 @@ class AnnotationLayerControl extends React.PureComponent {
({ borderRadius: theme.gridUnit })}> {annotations} - {' '}   {t('Add annotation layer')} - +
); diff --git a/superset-frontend/src/explore/components/controls/ControlPopover/ControlPopover.test.tsx b/superset-frontend/src/explore/components/controls/ControlPopover/ControlPopover.test.tsx new file mode 100644 index 000000000..de14f78f7 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/ControlPopover/ControlPopover.test.tsx @@ -0,0 +1,126 @@ +/** + * 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 } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/react'; + +import ControlPopover, { PopoverProps } from './ControlPopover'; + +const createProps = (): Partial => ({ + trigger: 'click', + title: 'Control Popover Test', + content: Information, +}); + +const setupTest = (props: Partial = createProps()) => { + const setStateMock = jest.fn(); + jest + .spyOn(React, 'useState') + .mockImplementation(((state: any) => [ + state, + state === 'right' ? setStateMock : jest.fn(), + ]) as any); + + const { container } = render( +
+
+ + Click me + +
+
, + ); + + return { + props, + container, + setStateMock, + }; +}; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('Should render', () => { + setupTest(); + expect(screen.getByTestId('control-popover')).toBeInTheDocument(); + userEvent.click(screen.getByTestId('control-popover')); + expect(screen.getByText('Control Popover Test')).toBeInTheDocument(); + expect(screen.getByTestId('control-popover-content')).toBeInTheDocument(); +}); + +test('Should lock the vertical scroll when the popup is visible', () => { + setupTest(); + expect(screen.getByTestId('control-popover')).toBeInTheDocument(); + expect(screen.getByTestId('outer-container')).not.toHaveStyle( + 'overflowY: hidden', + ); + userEvent.click(screen.getByTestId('control-popover')); + expect(screen.getByTestId('outer-container')).toHaveStyle( + 'overflowY: hidden', + ); + userEvent.click(document.body); + expect(screen.getByTestId('outer-container')).not.toHaveStyle( + 'overflowY: hidden', + ); +}); + +test('Should place popup at the top', async () => { + const { setStateMock } = setupTest({ + ...createProps(), + getVisibilityRatio: () => 0.2, + }); + + expect(screen.getByTestId('control-popover')).toBeInTheDocument(); + userEvent.click(screen.getByTestId('control-popover')); + + await waitFor(() => { + expect(setStateMock).toHaveBeenCalledWith('rightTop'); + }); +}); + +test('Should place popup at the center', async () => { + const { setStateMock } = setupTest({ + ...createProps(), + getVisibilityRatio: () => 0.5, + }); + + expect(screen.getByTestId('control-popover')).toBeInTheDocument(); + userEvent.click(screen.getByTestId('control-popover')); + + await waitFor(() => { + expect(setStateMock).toHaveBeenCalledWith('right'); + }); +}); + +test('Should place popup at the bottom', async () => { + const { setStateMock } = setupTest({ + ...createProps(), + getVisibilityRatio: () => 0.7, + }); + + expect(screen.getByTestId('control-popover')).toBeInTheDocument(); + userEvent.click(screen.getByTestId('control-popover')); + + await waitFor(() => { + expect(setStateMock).toHaveBeenCalledWith('rightBottom'); + }); +}); diff --git a/superset-frontend/src/explore/components/controls/ControlPopover/ControlPopover.tsx b/superset-frontend/src/explore/components/controls/ControlPopover/ControlPopover.tsx new file mode 100644 index 000000000..70ade69f2 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/ControlPopover/ControlPopover.tsx @@ -0,0 +1,118 @@ +/** + * 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, { useCallback, useRef, useEffect } from 'react'; + +import Popover, { + PopoverProps as BasePopoverProps, + TooltipPlacement, +} from 'src/components/Popover'; + +const sectionContainerId = 'controlSections'; +const getSectionContainerElement = () => + document.getElementById(sectionContainerId)?.parentElement; + +const getElementYVisibilityRatioOnContainer = (node: HTMLElement) => { + const containerHeight = window?.innerHeight; + const nodePositionInViewport = node.getBoundingClientRect()?.top; + if (!containerHeight || !nodePositionInViewport) { + return 0; + } + + return nodePositionInViewport / containerHeight; +}; + +export type PopoverProps = BasePopoverProps & { + getVisibilityRatio?: typeof getElementYVisibilityRatioOnContainer; +}; + +const ControlPopover: React.FC = ({ + getPopupContainer, + getVisibilityRatio = getElementYVisibilityRatioOnContainer, + ...props +}) => { + const triggerElementRef = useRef(); + const [placement, setPlacement] = React.useState('right'); + + const calculatePlacement = useCallback(() => { + const visibilityRatio = getVisibilityRatio(triggerElementRef.current!); + + if (visibilityRatio < 0.35) { + setPlacement('rightTop'); + } else if (visibilityRatio > 0.65) { + setPlacement('rightBottom'); + } else { + setPlacement('right'); + } + }, [getVisibilityRatio]); + + const changeContainerScrollStatus = useCallback( + visible => { + if (triggerElementRef.current && visible) { + calculatePlacement(); + } + + const element = getSectionContainerElement(); + if (element) { + element.style.overflowY = visible ? 'hidden' : 'auto'; + } + }, + [calculatePlacement], + ); + + const handleGetPopupContainer = useCallback( + (triggerNode: HTMLElement) => { + triggerElementRef.current = triggerNode; + setTimeout(() => { + calculatePlacement(); + }, 0); + + return getPopupContainer?.(triggerNode) || document.body; + }, + [calculatePlacement, getPopupContainer], + ); + + const handleOnVisibleChange = useCallback( + (visible: boolean) => { + if (props.visible === undefined) { + changeContainerScrollStatus(visible); + } + + props.onVisibleChange?.(visible); + }, + [props, changeContainerScrollStatus], + ); + + useEffect(() => { + if (props.visible !== undefined) { + changeContainerScrollStatus(props.visible); + } + }, [props.visible, changeContainerScrollStatus]); + + return ( + + ); +}; + +export default ControlPopover; diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx index 24ee1f870..682a961ec 100644 --- a/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterLabel.tsx @@ -31,7 +31,6 @@ import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import Button from 'src/components/Button'; import ControlHeader from 'src/explore/components/ControlHeader'; import Label, { Type } from 'src/components/Label'; -import Popover from 'src/components/Popover'; import { Divider } from 'src/components'; import Icons from 'src/components/Icons'; import Select from 'src/components/Select/Select'; @@ -42,6 +41,7 @@ import { SLOW_DEBOUNCE } from 'src/constants'; import { testWithId } from 'src/utils/testUtils'; import { noOp } from 'src/utils/common'; import { FrameType } from './types'; +import ControlPopover from '../ControlPopover/ControlPopover'; import { CommonFrame, @@ -86,7 +86,7 @@ const fetchTimeRange = async (timeRange: string) => { } }; -const StyledPopover = styled(Popover)``; +const StyledPopover = styled(ControlPopover)``; const StyledRangeType = styled(Select)` width: 272px; `; diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopoverTrigger.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopoverTrigger.tsx index 42251464d..62107753e 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopoverTrigger.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopoverTrigger.tsx @@ -23,10 +23,10 @@ import { isAdhocColumn, isColumnMeta, } from '@superset-ui/chart-controls'; -import Popover from 'src/components/Popover'; import { ExplorePopoverContent } from 'src/explore/components/ExploreContentPopover'; import ColumnSelectPopover from './ColumnSelectPopover'; import { DndColumnSelectPopoverTitle } from './DndColumnSelectPopoverTitle'; +import ControlPopover from '../ControlPopover/ControlPopover'; interface ColumnSelectPopoverTriggerProps { columns: ColumnMeta[]; @@ -137,8 +137,7 @@ const ColumnSelectPopoverTrigger = ({ ); return ( - {children} - + ); }; diff --git a/superset-frontend/src/explore/components/controls/FilterBoxItemControl/FilterBoxItemControl.test.jsx b/superset-frontend/src/explore/components/controls/FilterBoxItemControl/FilterBoxItemControl.test.jsx index 597c4446e..4cf88645d 100644 --- a/superset-frontend/src/explore/components/controls/FilterBoxItemControl/FilterBoxItemControl.test.jsx +++ b/superset-frontend/src/explore/components/controls/FilterBoxItemControl/FilterBoxItemControl.test.jsx @@ -21,10 +21,10 @@ import React from 'react'; import sinon from 'sinon'; import { shallow } from 'enzyme'; -import Popover from 'src/components/Popover'; import FilterBoxItemControl from 'src/explore/components/controls/FilterBoxItemControl'; import FormRow from 'src/components/FormRow'; import datasources from 'spec/fixtures/mockDatasource'; +import ControlPopover from '../ControlPopover/ControlPopover'; const defaultProps = { label: 'some label', @@ -46,7 +46,7 @@ describe('FilterBoxItemControl', () => { }); it('renders a Popover', () => { - expect(wrapper.find(Popover)).toExist(); + expect(wrapper.find(ControlPopover)).toExist(); }); it('renderForms does the job', () => { diff --git a/superset-frontend/src/explore/components/controls/FilterBoxItemControl/FilterBoxItemControl.test.tsx b/superset-frontend/src/explore/components/controls/FilterBoxItemControl/FilterBoxItemControl.test.tsx index 9bac8d253..4ad09e958 100644 --- a/superset-frontend/src/explore/components/controls/FilterBoxItemControl/FilterBoxItemControl.test.tsx +++ b/superset-frontend/src/explore/components/controls/FilterBoxItemControl/FilterBoxItemControl.test.tsx @@ -38,14 +38,14 @@ const createProps = () => ({ onChange: jest.fn(), }); -test('Shoud render', () => { +test('Should render', () => { const props = createProps(); render(); expect(screen.getByTestId('FilterBoxItemControl')).toBeInTheDocument(); expect(screen.getByRole('button')).toBeInTheDocument(); }); -test('Shoud open modal', () => { +test('Should open modal', () => { const props = createProps(); render(); userEvent.click(screen.getByRole('button')); diff --git a/superset-frontend/src/explore/components/controls/FilterBoxItemControl/index.jsx b/superset-frontend/src/explore/components/controls/FilterBoxItemControl/index.jsx index 25aa79f44..4c8a367e3 100644 --- a/superset-frontend/src/explore/components/controls/FilterBoxItemControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/FilterBoxItemControl/index.jsx @@ -21,12 +21,12 @@ import PropTypes from 'prop-types'; import { t } from '@superset-ui/core'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; -import Popover from 'src/components/Popover'; import FormRow from 'src/components/FormRow'; import { Select } from 'src/components'; import CheckboxControl from 'src/explore/components/controls/CheckboxControl'; import TextControl from 'src/explore/components/controls/TextControl'; import { FILTER_CONFIG_ATTRIBUTES } from 'src/explore/constants'; +import ControlPopover from '../ControlPopover/ControlPopover'; const INTEGRAL_TYPES = new Set([ 'TINYINT', @@ -275,9 +275,8 @@ export default class FilterBoxItemControl extends React.Component { return ( {this.textSummary()}{' '} - @@ -286,7 +285,7 @@ export default class FilterBoxItemControl extends React.Component { className="text-primary" label="edit-ts-column" /> - + ); } diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/AdhocFilterPopoverTrigger.test.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/AdhocFilterPopoverTrigger.test.tsx index 92d0ca801..36c711ebf 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/AdhocFilterPopoverTrigger.test.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/AdhocFilterPopoverTrigger.test.tsx @@ -73,7 +73,8 @@ test('should be visible when controlled', async () => { Click , ); - expect(screen.getByRole('tooltip')).toBeInTheDocument(); + + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); }); test('should NOT be visible when controlled', () => { diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx index 15913e65c..5dd6993c1 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx @@ -17,12 +17,12 @@ * under the License. */ import React from 'react'; -import Popover from 'src/components/Popover'; import { OptionSortType } from 'src/explore/types'; import AdhocFilterEditPopover from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopover'; import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; import { ExplorePopoverContent } from 'src/explore/components/ExploreContentPopover'; import { Operators } from 'src/explore/constants'; +import ControlPopover from '../../ControlPopover/ControlPopover'; interface AdhocFilterPopoverTriggerProps { sections?: string[]; @@ -101,8 +101,7 @@ class AdhocFilterPopoverTrigger extends React.PureComponent< ); return ( - {this.props.children} - + ); } } diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.jsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.jsx index 14b4185ab..3700bb083 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.jsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.jsx @@ -21,10 +21,10 @@ import React from 'react'; import sinon from 'sinon'; import { shallow } from 'enzyme'; -import Popover from 'src/components/Popover'; import { AGGREGATES } from 'src/explore/constants'; import AdhocMetricOption from 'src/explore/components/controls/MetricControl/AdhocMetricOption'; import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric'; +import ControlPopover from '../ControlPopover/ControlPopover'; const columns = [ { type: 'VARCHAR(255)', column_name: 'source' }, @@ -59,7 +59,7 @@ function setup(overrides) { describe('AdhocMetricOption', () => { it('renders an overlay trigger wrapper for the label', () => { const { wrapper } = setup(); - expect(wrapper.find(Popover)).toExist(); + expect(wrapper.find(ControlPopover)).toExist(); expect(wrapper.find('OptionControlLabel')).toExist(); }); diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx index 42c2c806b..273c83e7d 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx @@ -18,7 +18,6 @@ */ import React, { ReactNode } from 'react'; import { Datasource, Metric } from '@superset-ui/core'; -import Popover from 'src/components/Popover'; import AdhocMetricEditPopoverTitle from 'src/explore/components/controls/MetricControl/AdhocMetricEditPopoverTitle'; import { ExplorePopoverContent } from 'src/explore/components/ExploreContentPopover'; import AdhocMetricEditPopover, { @@ -26,6 +25,7 @@ import AdhocMetricEditPopover, { } from './AdhocMetricEditPopover'; import AdhocMetric from './AdhocMetric'; import { savedMetricType } from './types'; +import ControlPopover from '../ControlPopover/ControlPopover'; export type AdhocMetricPopoverTriggerProps = { adhocMetric: AdhocMetric; @@ -223,7 +223,7 @@ class AdhocMetricPopoverTrigger extends React.PureComponent< ); return ( - {this.props.children} - + ); } } diff --git a/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.jsx b/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.jsx index 29d0d16b6..5070c9a59 100644 --- a/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.jsx @@ -20,12 +20,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Input } from 'src/components/Input'; import Button from 'src/components/Button'; -import Popover from 'src/components/Popover'; import { Select, Row, Col } from 'src/components'; import { t, styled } from '@superset-ui/core'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import BoundsControl from '../BoundsControl'; import CheckboxControl from '../CheckboxControl'; +import ControlPopover from '../ControlPopover/ControlPopover'; const propTypes = { label: PropTypes.string, @@ -353,9 +353,8 @@ export default class TimeSeriesColumnControl extends React.Component { return ( {this.textSummary()}{' '} - - + ); }