diff --git a/superset-frontend/src/dashboard/components/AnchorLink/index.tsx b/superset-frontend/src/dashboard/components/AnchorLink/index.tsx index cfabaf51b7..a2541ce2a2 100644 --- a/superset-frontend/src/dashboard/components/AnchorLink/index.tsx +++ b/superset-frontend/src/dashboard/components/AnchorLink/index.tsx @@ -26,9 +26,10 @@ import getLocationHash from 'src/dashboard/util/getLocationHash'; export type AnchorLinkProps = { id: string; + dashboardId?: number; scrollIntoView?: boolean; showShortLinkButton?: boolean; -} & Pick; +} & Pick; export default function AnchorLink({ id, diff --git a/superset-frontend/src/dashboard/components/dnd/DragDroppable.jsx b/superset-frontend/src/dashboard/components/dnd/DragDroppable.jsx index a29d0475e5..b9c287c225 100644 --- a/superset-frontend/src/dashboard/components/dnd/DragDroppable.jsx +++ b/superset-frontend/src/dashboard/components/dnd/DragDroppable.jsx @@ -46,12 +46,12 @@ const propTypes = { useEmptyDragPreview: PropTypes.bool, // from react-dnd - isDragging: PropTypes.bool.isRequired, - isDraggingOver: PropTypes.bool.isRequired, - isDraggingOverShallow: PropTypes.bool.isRequired, - droppableRef: PropTypes.func.isRequired, - dragSourceRef: PropTypes.func.isRequired, - dragPreviewRef: PropTypes.func.isRequired, + isDragging: PropTypes.bool, + isDraggingOver: PropTypes.bool, + isDraggingOverShallow: PropTypes.bool, + droppableRef: PropTypes.func, + dragSourceRef: PropTypes.func, + dragPreviewRef: PropTypes.func, }; const defaultProps = { @@ -63,6 +63,12 @@ const defaultProps = { onDrop() {}, orientation: 'row', useEmptyDragPreview: false, + isDragging: false, + isDraggingOver: false, + isDraggingOverShallow: false, + droppableRef() {}, + dragSourceRef() {}, + dragPreviewRef() {}, }; // export unwrapped component for testing @@ -95,7 +101,7 @@ export class UnwrappedDragDroppable extends React.PureComponent { } else { this.props.dragPreviewRef(ref); } - this.props.droppableRef(ref); + this.props.droppableRef?.(ref); } render() { diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.jsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.jsx deleted file mode 100644 index 2363b1610e..0000000000 --- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.jsx +++ /dev/null @@ -1,420 +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 React from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; -import { useTheme } from '@superset-ui/core'; -import { useSelector, connect } from 'react-redux'; - -import { getChartIdsInFilterBoxScope } from 'src/dashboard/util/activeDashboardFilters'; -import Chart from 'src/dashboard/containers/Chart'; -import AnchorLink from 'src/dashboard/components/AnchorLink'; -import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton'; -import DragDroppable from 'src/dashboard/components/dnd/DragDroppable'; -import HoverMenu from 'src/dashboard/components/menu/HoverMenu'; -import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer'; -import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath'; -import { componentShape } from 'src/dashboard/util/propShapes'; -import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes'; -import { - GRID_BASE_UNIT, - GRID_GUTTER_SIZE, - GRID_MIN_COLUMN_COUNT, - GRID_MIN_ROW_UNITS, -} from 'src/dashboard/util/constants'; - -const CHART_MARGIN = 32; - -const propTypes = { - id: PropTypes.string.isRequired, - parentId: PropTypes.string.isRequired, - dashboardId: PropTypes.number.isRequired, - component: componentShape.isRequired, - parentComponent: componentShape.isRequired, - getComponentById: PropTypes.func.isRequired, - index: PropTypes.number.isRequired, - depth: PropTypes.number.isRequired, - editMode: PropTypes.bool.isRequired, - directPathToChild: PropTypes.arrayOf(PropTypes.string), - directPathLastUpdated: PropTypes.number, - focusedFilterScope: PropTypes.object, - fullSizeChartId: PropTypes.oneOf([PropTypes.number, null]), - - // grid related - availableColumnCount: PropTypes.number.isRequired, - columnWidth: PropTypes.number.isRequired, - onResizeStart: PropTypes.func.isRequired, - onResize: PropTypes.func.isRequired, - onResizeStop: PropTypes.func.isRequired, - - // dnd - deleteComponent: PropTypes.func.isRequired, - updateComponents: PropTypes.func.isRequired, - handleComponentDrop: PropTypes.func.isRequired, - setFullSizeChartId: PropTypes.func.isRequired, - postAddSliceFromDashboard: PropTypes.func, -}; - -const defaultProps = { - directPathToChild: [], - directPathLastUpdated: 0, -}; - -/** - * Selects the chart scope of the filter input that has focus. - * - * @returns {{chartId: number, scope: { scope: string[], immune: string[] }} | null } - * the scope of the currently focused filter, if any - */ -function selectFocusedFilterScope(dashboardState, dashboardFilters) { - if (!dashboardState.focusedFilterField) return null; - const { chartId, column } = dashboardState.focusedFilterField; - return { - chartId, - scope: dashboardFilters[chartId].scopes[column], - }; -} - -/** - * Renders any styles necessary to highlight the chart's relationship to the focused filter. - * - * If there is no focused filter scope (i.e. most of the time), this will be just a pass-through. - * - * If the chart is outside the scope of the focused filter, dims the chart. - * - * If the chart is in the scope of the focused filter, - * renders a highlight around the chart. - * - * If ChartHolder were a function component, this could be implemented as a hook instead. - */ -const FilterFocusHighlight = React.forwardRef( - ({ chartId, ...otherProps }, ref) => { - const theme = useTheme(); - - const nativeFilters = useSelector(state => state.nativeFilters); - const dashboardState = useSelector(state => state.dashboardState); - const dashboardFilters = useSelector(state => state.dashboardFilters); - const focusedFilterScope = selectFocusedFilterScope( - dashboardState, - dashboardFilters, - ); - const focusedNativeFilterId = nativeFilters.focusedFilterId; - if (!(focusedFilterScope || focusedNativeFilterId)) { - return
; - } - - // we use local styles here instead of a conditionally-applied class, - // because adding any conditional class to this container - // causes performance issues in Chrome. - - // default to the "de-emphasized" state - const unfocusedChartStyles = { opacity: 0.3, pointerEvents: 'none' }; - const focusedChartStyles = { - borderColor: theme.colors.primary.light2, - opacity: 1, - boxShadow: `0px 0px ${theme.gridUnit * 2}px ${theme.colors.primary.base}`, - pointerEvents: 'auto', - }; - - if (focusedNativeFilterId) { - if ( - nativeFilters.filters[focusedNativeFilterId]?.chartsInScope?.includes( - chartId, - ) - ) { - return
; - } - } else if ( - chartId === focusedFilterScope.chartId || - getChartIdsInFilterBoxScope({ - filterScope: focusedFilterScope.scope, - }).includes(chartId) - ) { - return
; - } - - // inline styles are used here due to a performance issue when adding/changing a class, which causes a reflow - return
; - }, -); - -class ChartHolder extends React.Component { - static renderInFocusCSS(columnName) { - return ( - - ); - } - - static getDerivedStateFromProps(props, state) { - const { component, directPathToChild, directPathLastUpdated } = props; - const { label: columnName, chart: chartComponentId } = - getChartAndLabelComponentIdFromPath(directPathToChild); - - if ( - directPathLastUpdated !== state.directPathLastUpdated && - component.id === chartComponentId - ) { - return { - outlinedComponentId: component.id, - outlinedColumnName: columnName, - directPathLastUpdated, - }; - } - return null; - } - - constructor(props) { - super(props); - this.state = { - isFocused: false, - outlinedComponentId: null, - outlinedColumnName: null, - directPathLastUpdated: 0, - extraControls: {}, - }; - - this.handleChangeFocus = this.handleChangeFocus.bind(this); - this.handleDeleteComponent = this.handleDeleteComponent.bind(this); - this.handleUpdateSliceName = this.handleUpdateSliceName.bind(this); - this.handleToggleFullSize = this.handleToggleFullSize.bind(this); - this.handleExtraControl = this.handleExtraControl.bind(this); - this.handlePostTransformProps = this.handlePostTransformProps.bind(this); - } - - componentDidMount() { - this.hideOutline({}, this.state); - } - - componentDidUpdate(prevProps, prevState) { - this.hideOutline(prevState, this.state); - } - - hideOutline(prevState, state) { - const { outlinedComponentId: timerKey } = state; - const { outlinedComponentId: prevTimerKey } = prevState; - - // because of timeout, there might be multiple charts showing outline - if (!!timerKey && !prevTimerKey) { - setTimeout(() => { - this.setState(() => ({ - outlinedComponentId: null, - outlinedColumnName: null, - })); - }, 2000); - } - } - - handleChangeFocus(nextFocus) { - this.setState(() => ({ isFocused: nextFocus })); - } - - handleDeleteComponent() { - const { deleteComponent, id, parentId } = this.props; - deleteComponent(id, parentId); - } - - handleUpdateSliceName(nextName) { - const { component, updateComponents } = this.props; - updateComponents({ - [component.id]: { - ...component, - meta: { - ...component.meta, - sliceNameOverride: nextName, - }, - }, - }); - } - - handleToggleFullSize() { - const { component, fullSizeChartId, setFullSizeChartId } = this.props; - const { chartId } = component.meta; - const isFullSize = fullSizeChartId === chartId; - setFullSizeChartId(isFullSize ? null : chartId); - } - - handleExtraControl(name, value) { - this.setState(prevState => ({ - extraControls: { - ...prevState.extraControls, - [name]: value, - }, - })); - } - - handlePostTransformProps(props) { - this.props.postAddSliceFromDashboard(); - return props; - } - - render() { - const { isFocused, extraControls } = this.state; - const { - component, - parentComponent, - index, - depth, - availableColumnCount, - columnWidth, - onResizeStart, - onResize, - onResizeStop, - handleComponentDrop, - editMode, - isComponentVisible, - dashboardId, - fullSizeChartId, - getComponentById = () => undefined, - } = this.props; - - const { chartId } = component.meta; - const isFullSize = fullSizeChartId === chartId; - - // inherit the size of parent columns - const columnParentWidth = getComponentById( - parentComponent.parents?.find(parent => parent.startsWith(COLUMN_TYPE)), - )?.meta?.width; - let widthMultiple = component.meta.width || GRID_MIN_COLUMN_COUNT; - if (parentComponent.type === COLUMN_TYPE) { - widthMultiple = parentComponent.meta.width || GRID_MIN_COLUMN_COUNT; - } else if (columnParentWidth && widthMultiple > columnParentWidth) { - widthMultiple = columnParentWidth; - } - - let chartWidth = 0; - let chartHeight = 0; - - if (isFullSize) { - chartWidth = window.innerWidth - CHART_MARGIN; - chartHeight = window.innerHeight - CHART_MARGIN; - } else { - chartWidth = Math.floor( - widthMultiple * columnWidth + - (widthMultiple - 1) * GRID_GUTTER_SIZE - - CHART_MARGIN, - ); - chartHeight = Math.floor( - component.meta.height * GRID_BASE_UNIT - CHART_MARGIN, - ); - } - - return ( - - {({ dropIndicatorProps, dragSourceRef }) => ( - - - {!editMode && ( - - )} - {!!this.state.outlinedComponentId && - ChartHolder.renderInFocusCSS(this.state.outlinedColumnName)} - - {editMode && ( - -
- -
-
- )} -
- - {dropIndicatorProps &&
} - - )} - - ); - } -} - -ChartHolder.propTypes = propTypes; -ChartHolder.defaultProps = defaultProps; - -function mapStateToProps(state) { - return { - directPathToChild: state.dashboardState.directPathToChild, - directPathLastUpdated: state.dashboardState.directPathLastUpdated, - }; -} -export default connect(mapStateToProps)(ChartHolder); diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.jsx deleted file mode 100644 index 0c46b16de4..0000000000 --- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.jsx +++ /dev/null @@ -1,138 +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 { Provider } from 'react-redux'; -import React from 'react'; -import { styledMount as mount } from 'spec/helpers/theming'; -import sinon from 'sinon'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; - -import Chart from 'src/dashboard/containers/Chart'; -import ChartHolderConnected from 'src/dashboard/components/gridComponents/ChartHolder'; -import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton'; -import DragDroppable from 'src/dashboard/components/dnd/DragDroppable'; -import HoverMenu from 'src/dashboard/components/menu/HoverMenu'; -import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer'; - -import { getMockStore } from 'spec/fixtures/mockStore'; -import { sliceId } from 'spec/fixtures/mockChartQueries'; -import dashboardInfo from 'spec/fixtures/mockDashboardInfo'; -import { nativeFilters } from 'spec/fixtures/mockNativeFilters'; -import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout'; -import { sliceEntitiesForChart } from 'spec/fixtures/mockSliceEntities'; -import { initialState } from 'src/SqlLab/fixtures'; - -describe('ChartHolder', () => { - const props = { - id: String(sliceId), - dashboardId: dashboardInfo.id, - parentId: 'ROW_ID', - component: mockLayout.present.CHART_ID, - depth: 2, - parentComponent: mockLayout.present.ROW_ID, - index: 0, - editMode: false, - availableColumnCount: 12, - columnWidth: 50, - onResizeStart() {}, - onResize() {}, - onResizeStop() {}, - handleComponentDrop() {}, - updateComponents() {}, - deleteComponent() {}, - nativeFilters: nativeFilters.filters, - }; - - function setup(overrideProps) { - const mockStore = getMockStore({ - ...initialState, - sliceEntities: sliceEntitiesForChart, - }); - - // We have to wrap provide DragDropContext for the underlying DragDroppable - // otherwise we cannot assert on DragDroppable children - const wrapper = mount( - - - - - , - ); - return wrapper; - } - - it('should render a DragDroppable', () => { - const wrapper = setup(); - expect(wrapper.find(DragDroppable)).toExist(); - }); - - it('should render a ResizableContainer', () => { - const wrapper = setup(); - expect(wrapper.find(ResizableContainer)).toExist(); - }); - - it('should only have an adjustableWidth if its parent is a Row', () => { - let wrapper = setup(); - expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).toBe(true); - - wrapper = setup({ ...props, parentComponent: mockLayout.present.CHART_ID }); - expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).toBe( - false, - ); - }); - - it('should pass correct props to ResizableContainer', () => { - const wrapper = setup(); - const resizableProps = wrapper.find(ResizableContainer).props(); - expect(resizableProps.widthStep).toBe(props.columnWidth); - expect(resizableProps.widthMultiple).toBe(props.component.meta.width); - expect(resizableProps.heightMultiple).toBe(props.component.meta.height); - expect(resizableProps.maxWidthMultiple).toBe( - props.component.meta.width + props.availableColumnCount, - ); - }); - - it('should render a div with class "dashboard-component-chart-holder"', () => { - const wrapper = setup(); - expect(wrapper.find('.dashboard-component-chart-holder')).toExist(); - }); - - it('should render a Chart', () => { - const wrapper = setup(); - expect(wrapper.find(Chart)).toExist(); - }); - - it('should render a HoverMenu with DeleteComponentButton in editMode', () => { - let wrapper = setup(); - expect(wrapper.find(HoverMenu)).not.toExist(); - expect(wrapper.find(DeleteComponentButton)).not.toExist(); - - // we cannot set props on the Divider because of the WithDragDropContext wrapper - wrapper = setup({ editMode: true }); - expect(wrapper.find(HoverMenu)).toExist(); - expect(wrapper.find(DeleteComponentButton)).toExist(); - }); - - it('should call deleteComponent when deleted', () => { - const deleteComponent = sinon.spy(); - const wrapper = setup({ editMode: true, deleteComponent }); - wrapper.find(DeleteComponentButton).simulate('click'); - expect(deleteComponent.callCount).toBe(1); - }); -}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx index 759ffb81a9..62ad0584f0 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx @@ -18,22 +18,40 @@ */ import React from 'react'; +import { combineReducers, createStore, applyMiddleware, compose } from 'redux'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; +import sinon from 'sinon'; +import userEvent from '@testing-library/user-event'; import mockState from 'spec/fixtures/mockState'; +import reducerIndex from 'spec/helpers/reducerIndex'; import { sliceId as chartId } from 'spec/fixtures/mockChartQueries'; -import { screen, render } from 'spec/helpers/testing-library'; +import { + screen, + render, + waitFor, + fireEvent, +} from 'spec/helpers/testing-library'; import { nativeFiltersInfo } from 'src/dashboard/fixtures/mockNativeFilters'; import newComponentFactory from 'src/dashboard/util/newComponentFactory'; import { initialState } from 'src/SqlLab/fixtures'; -import { CHART_TYPE, ROW_TYPE } from '../../util/componentTypes'; -import { ChartHolder } from './index'; +import { SET_DIRECT_PATH } from 'src/dashboard/actions/dashboardState'; +import { CHART_TYPE, COLUMN_TYPE, ROW_TYPE } from '../../util/componentTypes'; +import ChartHolder, { CHART_MARGIN } from './ChartHolder'; +import { GRID_BASE_UNIT, GRID_GUTTER_SIZE } from '../../util/constants'; + +const DEFAULT_HEADER_HEIGHT = 22; describe('ChartHolder', () => { + let scrollViewBase: any; + const defaultProps = { component: { ...newComponentFactory(CHART_TYPE), - id: 'CHART_ID', + id: 'CHART-ID', parents: ['ROOT_ID', 'TABS_ID', 'TAB_ID', 'ROW_ID'], meta: { + uuid: `CHART-${chartId}`, chartId, width: 3, height: 10, @@ -47,7 +65,7 @@ describe('ChartHolder', () => { }, index: 0, depth: 0, - id: 'CHART_ID', + id: 'CHART-ID', parentId: 'ROW_ID', availableColumnCount: 12, columnWidth: 300, @@ -65,12 +83,28 @@ describe('ChartHolder', () => { setFullSizeChartId: () => {}, }; - const renderWrapper = () => - render(, { + beforeAll(() => { + scrollViewBase = window.HTMLElement.prototype.scrollIntoView; + window.HTMLElement.prototype.scrollIntoView = () => {}; + }); + + afterAll(() => { + window.HTMLElement.prototype.scrollIntoView = scrollViewBase; + }); + + const createMockStore = (customState: any = {}) => + createStore( + combineReducers(reducerIndex), + { ...mockState, ...(initialState as any), ...customState }, + compose(applyMiddleware(thunk)), + ); + + const renderWrapper = (store = createMockStore(), props: any = {}) => + render(, { useRouter: true, useDnd: true, useRedux: true, - initialState: { ...mockState, ...initialState }, + store, }); it('should render empty state', async () => { @@ -86,4 +120,314 @@ describe('ChartHolder', () => { ).not.toBeInTheDocument(); // description should display only in Explore view expect(screen.getByAltText('empty')).toBeVisible(); }); + + it('should render anchor link when not editing', async () => { + const store = createMockStore(); + const { rerender } = renderWrapper(store, { editMode: false }); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toBeVisible(); + + expect( + screen + .getByTestId('dashboard-component-chart-holder') + .getElementsByClassName('anchor-link-container').length, + ).toEqual(1); + + rerender( + + + , + ); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toBeVisible(); + + expect( + screen + .getByTestId('dashboard-component-chart-holder') + .getElementsByClassName('anchor-link-container').length, + ).toEqual(0); + }); + + it('should highlight when path matches', async () => { + const store = createMockStore({ + dashboardState: { + ...mockState.dashboardState, + directPathToChild: ['CHART-ID'], + }, + }); + renderWrapper(store); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toBeVisible(); + + expect(screen.getByTestId('dashboard-component-chart-holder')).toHaveClass( + 'fade-out', + ); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).not.toHaveClass('fade-in'); + + store.dispatch({ type: SET_DIRECT_PATH, path: ['CHART-ID'] }); + + await waitFor(() => { + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).not.toHaveClass('fade-out'); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toHaveClass('fade-in'); + }); + + await waitFor( + () => { + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toHaveClass('fade-out'); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).not.toHaveClass('fade-in'); + }, + { timeout: 5000 }, + ); + }); + + it('should calculate the default widthMultiple', async () => { + const widthMultiple = 5; + renderWrapper(createMockStore(), { + editMode: true, + component: { + ...defaultProps.component, + meta: { + ...defaultProps.component.meta, + width: widthMultiple, + }, + }, + }); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toBeVisible(); + + const resizeContainer = screen + .getByTestId('dragdroppable-object') + .getElementsByClassName('resizable-container')[0]; + + const { width: computedWidth } = getComputedStyle(resizeContainer); + const expectedWidth = + (defaultProps.columnWidth + GRID_GUTTER_SIZE) * widthMultiple - + GRID_GUTTER_SIZE; + + expect(computedWidth).toEqual(`${expectedWidth}px`); + }); + + it('should set the resizable width to auto when parent component type is column', async () => { + renderWrapper(createMockStore(), { + editMode: true, + parentComponent: { + ...newComponentFactory(COLUMN_TYPE), + id: 'ROW_ID', + children: ['COLUMN_ID'], + }, + }); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toBeVisible(); + + const resizeContainer = screen + .getByTestId('dragdroppable-object') + .getElementsByClassName('resizable-container')[0]; + + const { width: computedWidth } = getComputedStyle(resizeContainer); + + // the width is only adjustable if the parent component is row type + expect(computedWidth).toEqual('auto'); + }); + + it("should override the widthMultiple if there's a column in the parent chain whose width is less than the chart", async () => { + const widthMultiple = 10; + const parentColumnWidth = 6; + renderWrapper(createMockStore(), { + editMode: true, + component: { + ...defaultProps.component, + meta: { + ...defaultProps.component.meta, + width: widthMultiple, + }, + }, + // Return the first column in the chain + getComponentById: () => + newComponentFactory(COLUMN_TYPE, { width: parentColumnWidth }), + }); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toBeVisible(); + + const resizeContainer = screen + .getByTestId('dragdroppable-object') + .getElementsByClassName('resizable-container')[0]; + + const { width: computedWidth } = getComputedStyle(resizeContainer); + const expectedWidth = + (defaultProps.columnWidth + GRID_GUTTER_SIZE) * parentColumnWidth - + GRID_GUTTER_SIZE; + + expect(computedWidth).toEqual(`${expectedWidth}px`); + }); + + it('should calculate the chartWidth', async () => { + const widthMultiple = 7; + const columnWidth = 250; + renderWrapper(createMockStore(), { + fullSizeChartId: null, + component: { + ...defaultProps.component, + meta: { + ...defaultProps.component.meta, + width: widthMultiple, + }, + }, + columnWidth, + }); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toBeVisible(); + + const container = screen.getByTestId('chart-container'); + + const computedWidth = parseInt(container.getAttribute('width') || '0', 10); + const expectedWidth = Math.floor( + widthMultiple * columnWidth + + (widthMultiple - 1) * GRID_GUTTER_SIZE - + CHART_MARGIN, + ); + + expect(computedWidth).toEqual(expectedWidth); + }); + + it('should calculate the chartWidth on full screen mode', async () => { + const widthMultiple = 7; + const columnWidth = 250; + renderWrapper(createMockStore(), { + component: { + ...defaultProps.component, + meta: { + ...defaultProps.component.meta, + width: widthMultiple, + }, + }, + columnWidth, + }); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toBeVisible(); + + const container = screen.getByTestId('chart-container'); + + const computedWidth = parseInt(container.getAttribute('width') || '0', 10); + const expectedWidth = window.innerWidth - CHART_MARGIN; + + expect(computedWidth).toEqual(expectedWidth); + }); + + it('should calculate the chartHeight', async () => { + const heightMultiple = 12; + renderWrapper(createMockStore(), { + fullSizeChartId: null, + component: { + ...defaultProps.component, + meta: { + ...defaultProps.component.meta, + height: heightMultiple, + }, + }, + }); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toBeVisible(); + + const container = screen.getByTestId('chart-container'); + + const computedWidth = parseInt(container.getAttribute('height') || '0', 10); + const expectedWidth = Math.floor( + heightMultiple * GRID_BASE_UNIT - CHART_MARGIN - DEFAULT_HEADER_HEIGHT, + ); + + expect(computedWidth).toEqual(expectedWidth); + }); + + it('should calculate the chartHeight on full screen mode', async () => { + const heightMultiple = 12; + renderWrapper(createMockStore(), { + component: { + ...defaultProps.component, + meta: { + ...defaultProps.component.meta, + height: heightMultiple, + }, + }, + }); + + expect( + screen.getByTestId('dashboard-component-chart-holder'), + ).toBeVisible(); + + const container = screen.getByTestId('chart-container'); + + const computedWidth = parseInt(container.getAttribute('height') || '0', 10); + const expectedWidth = + window.innerHeight - CHART_MARGIN - DEFAULT_HEADER_HEIGHT; + + expect(computedWidth).toEqual(expectedWidth); + }); + + it('should call deleteComponent when deleted', async () => { + const deleteComponent = sinon.spy(); + const store = createMockStore(); + const { rerender } = renderWrapper(store, { + editMode: false, + fullSizeChartId: null, + deleteComponent, + }); + + expect( + screen.queryByTestId('dashboard-delete-component-button'), + ).not.toBeInTheDocument(); + + rerender( + + + , + ); + + expect( + screen.getByTestId('dashboard-delete-component-button'), + ).toBeInTheDocument(); + + userEvent.hover(screen.getByTestId('dashboard-component-chart-holder')); + + fireEvent.click( + screen.getByTestId('dashboard-delete-component-button') + .firstElementChild!, + ); + expect(deleteComponent.callCount).toBe(1); + }); }); diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx new file mode 100644 index 0000000000..c3cffe338c --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx @@ -0,0 +1,333 @@ +/** + * 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, useMemo, useCallback, useEffect } from 'react'; +import { ResizeCallback, ResizeStartCallback } from 're-resizable'; +import cx from 'classnames'; +import { useSelector } from 'react-redux'; + +import { LayoutItem, RootState } from 'src/dashboard/types'; +import AnchorLink from 'src/dashboard/components/AnchorLink'; +import Chart from 'src/dashboard/containers/Chart'; +import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton'; +import DragDroppable from 'src/dashboard/components/dnd/DragDroppable'; +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 { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes'; +import { + GRID_BASE_UNIT, + GRID_GUTTER_SIZE, + GRID_MIN_COLUMN_COUNT, + GRID_MIN_ROW_UNITS, +} from 'src/dashboard/util/constants'; + +export const CHART_MARGIN = 32; + +interface ChartHolderProps { + id: string; + parentId: string; + dashboardId: number; + component: LayoutItem; + parentComponent: LayoutItem; + getComponentById?: (id?: string) => LayoutItem | undefined; + index: number; + depth: number; + editMode: boolean; + directPathLastUpdated?: number; + fullSizeChartId: number | null; + isComponentVisible: boolean; + + // grid related + availableColumnCount: number; + columnWidth: number; + onResizeStart: ResizeStartCallback; + onResize: ResizeCallback; + onResizeStop: ResizeCallback; + + // dnd + deleteComponent: (id: string, parentId: string) => void; + updateComponents: Function; + handleComponentDrop: (...args: unknown[]) => unknown; + setFullSizeChartId: (chartId: number | null) => void; + postAddSliceFromDashboard?: () => void; +} + +const ChartHolder: React.FC = ({ + id, + parentId, + component, + parentComponent, + index, + depth, + availableColumnCount, + columnWidth, + onResizeStart, + onResize, + onResizeStop, + editMode, + isComponentVisible, + dashboardId, + fullSizeChartId, + getComponentById = () => undefined, + deleteComponent, + updateComponents, + handleComponentDrop, + setFullSizeChartId, + postAddSliceFromDashboard, +}) => { + const { chartId } = component.meta; + const isFullSize = fullSizeChartId === chartId; + + const focusHighlightStyles = useFilterFocusHighlightStyles(chartId); + const dashboardState = useSelector( + (state: RootState) => state.dashboardState, + ); + const [extraControls, setExtraControls] = useState>( + {}, + ); + const [outlinedComponentId, setOutlinedComponentId] = useState(); + const [outlinedColumnName, setOutlinedColumnName] = useState(); + const [currentDirectPathLastUpdated, setCurrentDirectPathLastUpdated] = + useState(0); + + const directPathToChild = useMemo( + () => dashboardState?.directPathToChild ?? [], + [dashboardState], + ); + + const directPathLastUpdated = useMemo( + () => dashboardState?.directPathLastUpdated ?? 0, + [dashboardState], + ); + + const infoFromPath = useMemo( + () => getChartAndLabelComponentIdFromPath(directPathToChild) as any, + [directPathToChild], + ); + + // Calculate if the chart should be outlined + useEffect(() => { + const { label: columnName, chart: chartComponentId } = infoFromPath; + + if ( + directPathLastUpdated !== currentDirectPathLastUpdated && + component.id === chartComponentId + ) { + setCurrentDirectPathLastUpdated(directPathLastUpdated); + setOutlinedComponentId(component.id); + setOutlinedColumnName(columnName); + } + }, [ + component, + currentDirectPathLastUpdated, + directPathLastUpdated, + infoFromPath, + ]); + + // Remove the chart outline after a defined time + useEffect(() => { + let timerId: NodeJS.Timeout | undefined; + if (outlinedComponentId) { + timerId = setTimeout(() => { + setOutlinedComponentId(undefined); + setOutlinedColumnName(undefined); + }, 2000); + } + + return () => { + if (timerId) { + clearTimeout(timerId); + } + }; + }, [outlinedComponentId]); + + const widthMultiple = useMemo(() => { + const columnParentWidth = getComponentById( + parentComponent.parents?.find(parent => parent.startsWith(COLUMN_TYPE)), + )?.meta?.width; + + let widthMultiple = component.meta.width || GRID_MIN_COLUMN_COUNT; + if (parentComponent.type === COLUMN_TYPE) { + widthMultiple = parentComponent.meta.width || GRID_MIN_COLUMN_COUNT; + } else if (columnParentWidth && widthMultiple > columnParentWidth) { + widthMultiple = columnParentWidth; + } + + return widthMultiple; + }, [ + component, + getComponentById, + parentComponent.meta.width, + parentComponent.parents, + parentComponent.type, + ]); + + const { chartWidth, chartHeight } = useMemo(() => { + let chartWidth = 0; + let chartHeight = 0; + + if (isFullSize) { + chartWidth = window.innerWidth - CHART_MARGIN; + chartHeight = window.innerHeight - CHART_MARGIN; + } else { + chartWidth = Math.floor( + widthMultiple * columnWidth + + (widthMultiple - 1) * GRID_GUTTER_SIZE - + CHART_MARGIN, + ); + chartHeight = Math.floor( + component.meta.height * GRID_BASE_UNIT - CHART_MARGIN, + ); + } + + return { + chartWidth, + chartHeight, + }; + }, [columnWidth, component, isFullSize, widthMultiple]); + + const handleDeleteComponent = useCallback(() => { + deleteComponent(id, parentId); + }, [deleteComponent, id, parentId]); + + const handleUpdateSliceName = useCallback( + (nextName: string) => { + updateComponents({ + [component.id]: { + ...component, + meta: { + ...component.meta, + sliceNameOverride: nextName, + }, + }, + }); + }, + [component, updateComponents], + ); + + const handleToggleFullSize = useCallback(() => { + setFullSizeChartId(isFullSize ? null : chartId); + }, [chartId, isFullSize, setFullSizeChartId]); + + const handleExtraControl = useCallback((name: string, value: unknown) => { + setExtraControls(current => ({ + ...current, + [name]: value, + })); + }, []); + + const handlePostTransformProps = useCallback( + (props: unknown) => { + postAddSliceFromDashboard?.(); + return props; + }, + [postAddSliceFromDashboard], + ); + + return ( + + {({ dropIndicatorProps, dragSourceRef }) => ( + +
+ {!editMode && ( + + )} + {!!outlinedComponentId && ( + + )} + + {editMode && ( + +
+ +
+
+ )} +
+ {dropIndicatorProps &&
} + + )} + + ); +}; + +export default ChartHolder; diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index b2617ea779..31cc4bbded 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -67,6 +67,11 @@ export type DashboardState = { hasUnsavedChanges: boolean; colorScheme: string; sliceIds: number[]; + directPathLastUpdated: number; + focusedFilterField?: { + chartId: number; + column: string; + }; }; export type DashboardInfo = { id: number; diff --git a/superset-frontend/src/dashboard/util/useFilterFocusHighlightStyles.test.tsx b/superset-frontend/src/dashboard/util/useFilterFocusHighlightStyles.test.tsx new file mode 100644 index 0000000000..1d4d5af367 --- /dev/null +++ b/superset-frontend/src/dashboard/util/useFilterFocusHighlightStyles.test.tsx @@ -0,0 +1,209 @@ +/** + * 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 { combineReducers, createStore, applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import mockState from 'spec/fixtures/mockState'; +import reducerIndex from 'spec/helpers/reducerIndex'; +import { screen, render } from 'spec/helpers/testing-library'; +import { initialState } from 'src/SqlLab/fixtures'; +import { dashboardFilters } from 'spec/fixtures/mockDashboardFilters'; +import { dashboardWithFilter } from 'spec/fixtures/mockDashboardLayout'; +import { buildActiveFilters } from './activeDashboardFilters'; +import useFilterFocusHighlightStyles from './useFilterFocusHighlightStyles'; + +const TestComponent = ({ chartId }: { chartId: number }) => { + const styles = useFilterFocusHighlightStyles(chartId); + + return
; +}; + +describe('useFilterFocusHighlightStyles', () => { + const createMockStore = (customState: any = {}) => + createStore( + combineReducers(reducerIndex), + { ...mockState, ...(initialState as any), ...customState }, + compose(applyMiddleware(thunk)), + ); + + const renderWrapper = (chartId: number, store = createMockStore()) => + render(, { + useRouter: true, + useDnd: true, + useRedux: true, + store, + }); + + it('should return no style if filter not in scope', async () => { + renderWrapper(10); + + const container = screen.getByTestId('test-component'); + + const styles = getComputedStyle(container); + expect(styles.opacity).toBeFalsy(); + }); + + it('should return unfocused styles if chart is not in scope of focused native filter', async () => { + const store = createMockStore({ + nativeFilters: { + focusedFilterId: 'test-filter', + filters: { + otherId: { + chartsInScope: [], + }, + }, + }, + }); + renderWrapper(10, store); + + const container = screen.getByTestId('test-component'); + + const styles = getComputedStyle(container); + expect(parseFloat(styles.opacity)).toBe(0.3); + }); + + it('should return focused styles if chart is in scope of focused native filter', async () => { + const chartId = 18; + const store = createMockStore({ + nativeFilters: { + focusedFilterId: 'testFilter', + filters: { + testFilter: { + chartsInScope: [chartId], + }, + }, + }, + }); + renderWrapper(chartId, store); + + const container = screen.getByTestId('test-component'); + + const styles = getComputedStyle(container); + expect(parseFloat(styles.opacity)).toBe(1); + }); + + it('should return unfocused styles if focusedFilterField is targeting a different chart', async () => { + const chartId = 18; + const store = createMockStore({ + dashboardState: { + focusedFilterField: { + chartId: 10, + column: 'test', + }, + }, + dashboardFilters: { + 10: { + scopes: {}, + }, + }, + }); + renderWrapper(chartId, store); + + const container = screen.getByTestId('test-component'); + + const styles = getComputedStyle(container); + expect(parseFloat(styles.opacity)).toBe(0.3); + }); + + it('should return focused styles if focusedFilterField chart equals our own', async () => { + const chartId = 18; + const store = createMockStore({ + dashboardState: { + focusedFilterField: { + chartId, + column: 'test', + }, + }, + dashboardFilters: { + [chartId]: { + scopes: { + otherColumn: {}, + }, + }, + }, + }); + renderWrapper(chartId, store); + + const container = screen.getByTestId('test-component'); + + const styles = getComputedStyle(container); + expect(parseFloat(styles.opacity)).toBe(1); + }); + + it('should return unfocused styles if chart is not inside filter box scope', async () => { + buildActiveFilters({ + dashboardFilters, + components: dashboardWithFilter, + }); + + const chartId = 18; + const store = createMockStore({ + dashboardState: { + focusedFilterField: { + chartId, + column: 'test', + }, + }, + dashboardFilters: { + [chartId]: { + scopes: { + column: {}, + }, + }, + }, + }); + renderWrapper(20, store); + + const container = screen.getByTestId('test-component'); + + const styles = getComputedStyle(container); + expect(parseFloat(styles.opacity)).toBe(0.3); + }); + + it('should return focused styles if chart is inside filter box scope', async () => { + buildActiveFilters({ + dashboardFilters, + components: dashboardWithFilter, + }); + + const chartId = 18; + const store = createMockStore({ + dashboardState: { + focusedFilterField: { + chartId, + column: 'test', + }, + }, + dashboardFilters: { + [chartId]: { + scopes: { + column: {}, + }, + }, + }, + }); + renderWrapper(chartId, store); + + const container = screen.getByTestId('test-component'); + + const styles = getComputedStyle(container); + expect(parseFloat(styles.opacity)).toBe(1); + }); +}); diff --git a/superset-frontend/src/dashboard/util/useFilterFocusHighlightStyles.ts b/superset-frontend/src/dashboard/util/useFilterFocusHighlightStyles.ts new file mode 100644 index 0000000000..958dce57fc --- /dev/null +++ b/superset-frontend/src/dashboard/util/useFilterFocusHighlightStyles.ts @@ -0,0 +1,91 @@ +/** + * 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 { useTheme } from '@superset-ui/core'; +import { useSelector } from 'react-redux'; + +import { getChartIdsInFilterBoxScope } from 'src/dashboard/util/activeDashboardFilters'; +import { DashboardState, RootState } from 'src/dashboard/types'; + +const selectFocusedFilterScope = ( + dashboardState: DashboardState, + dashboardFilters: any, +) => { + if (!dashboardState.focusedFilterField) return null; + const { chartId, column } = dashboardState.focusedFilterField; + return { + chartId, + scope: dashboardFilters[chartId].scopes[column], + }; +}; + +const useFilterFocusHighlightStyles = (chartId: number) => { + const theme = useTheme(); + + const nativeFilters = useSelector((state: RootState) => state.nativeFilters); + const dashboardState = useSelector( + (state: RootState) => state.dashboardState, + ); + const dashboardFilters = useSelector( + (state: RootState) => state.dashboardFilters, + ); + const focusedFilterScope = selectFocusedFilterScope( + dashboardState, + dashboardFilters, + ); + + const focusedNativeFilterId = nativeFilters.focusedFilterId; + if (!(focusedFilterScope || focusedNativeFilterId)) { + return {}; + } + + // we use local styles here instead of a conditionally-applied class, + // because adding any conditional class to this container + // causes performance issues in Chrome. + + // default to the "de-emphasized" state + const unfocusedChartStyles = { opacity: 0.3, pointerEvents: 'none' }; + const focusedChartStyles = { + borderColor: theme.colors.primary.light2, + opacity: 1, + boxShadow: `0px 0px ${theme.gridUnit * 2}px ${theme.colors.primary.base}`, + pointerEvents: 'auto', + }; + + if (focusedNativeFilterId) { + if ( + nativeFilters.filters[focusedNativeFilterId]?.chartsInScope?.includes( + chartId, + ) + ) { + return focusedChartStyles; + } + } else if ( + chartId === focusedFilterScope?.chartId || + getChartIdsInFilterBoxScope({ + filterScope: focusedFilterScope?.scope, + }).includes(chartId) + ) { + return focusedChartStyles; + } + + // inline styles are used here due to a performance issue when adding/changing a class, which causes a reflow + return unfocusedChartStyles; +}; + +export default useFilterFocusHighlightStyles;