diff --git a/superset-frontend/src/dashboard/components/DashboardGrid.jsx b/superset-frontend/src/dashboard/components/DashboardGrid.jsx index 704a9283b3208..6c6d5f853a54c 100644 --- a/superset-frontend/src/dashboard/components/DashboardGrid.jsx +++ b/superset-frontend/src/dashboard/components/DashboardGrid.jsx @@ -56,7 +56,6 @@ const GridContent = styled.div` ${({ theme, editMode }) => css` display: flex; flex-direction: column; - /* gutters between rows */ & > div:not(:last-child):not(.empty-droptarget) { ${!editMode && `margin-bottom: ${theme.gridUnit * 4}px`}; @@ -154,8 +153,12 @@ class DashboardGrid extends PureComponent { })); } - handleResizeStop({ id, widthMultiple: width, heightMultiple: height }) { - this.props.resizeComponent({ id, width, height }); + handleResizeStop(_event, _direction, _elementRef, delta, id) { + this.props.resizeComponent({ + id, + width: delta.width, + height: delta.height, + }); this.setState(() => ({ isResizing: false, diff --git a/superset-frontend/src/dashboard/components/DashboardGrid.test.jsx b/superset-frontend/src/dashboard/components/DashboardGrid.test.jsx index 7f412aa0f4cb3..a81d6f4e9a41f 100644 --- a/superset-frontend/src/dashboard/components/DashboardGrid.test.jsx +++ b/superset-frontend/src/dashboard/components/DashboardGrid.test.jsx @@ -24,8 +24,6 @@ import newComponentFactory from 'src/dashboard/util/newComponentFactory'; import { DASHBOARD_GRID_TYPE } from 'src/dashboard/util/componentTypes'; import { GRID_COLUMN_COUNT } from 'src/dashboard/util/constants'; -const args = { id: 'id', widthMultiple: 1, heightMultiple: 3 }; - jest.mock( 'src/dashboard/containers/DashboardComponent', () => @@ -34,7 +32,9 @@ jest.mock( type="button" data-test="mock-dashboard-component" onClick={() => onResizeStart()} - onBlur={() => onResizeStop(args)} + onBlur={() => + onResizeStop(null, null, null, { width: 1, height: 3 }, 'id') + } > Mock diff --git a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx index 3b97c277d9c72..66db0fd898d0f 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx @@ -21,6 +21,7 @@ import { DashboardComponentMetadata, JsonObject, t } from '@superset-ui/core'; import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions'; import cx from 'classnames'; import { shallowEqual, useSelector } from 'react-redux'; +import { ResizeCallback, ResizeStartCallback } from 're-resizable'; import { Draggable } from '../dnd/DragDroppable'; import { COLUMN_TYPE, ROW_TYPE } from '../../util/componentTypes'; import WithPopoverMenu from '../menu/WithPopoverMenu'; @@ -45,9 +46,9 @@ type FilterSummaryType = { editMode: boolean; columnWidth: number; availableColumnCount: number; - onResizeStart: Function; - onResizeStop: Function; - onResize: Function; + onResizeStart: ResizeStartCallback; + onResizeStop: ResizeCallback; + onResize: ResizeCallback; deleteComponent: Function; updateComponents: Function; parentId: number; @@ -140,6 +141,7 @@ const DynamicComponent: FC = ({ > css` - &.resizable-container { - background-color: transparent; - position: relative; - - /* re-resizable sets an empty div to 100% width and height, which doesn't - play well with many 100% height containers we need */ - - & ~ div { - width: auto !important; - height: auto !important; - } - } - - &.resizable-container--resizing { - /* after ensures border visibility on top of any children */ - - &:after { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - box-shadow: inset 0 0 0 2px ${theme.colors.primary.base}; - } - - & > span .resize-handle { - border-color: ${theme.colors.primary.base}; - } - } - - .resize-handle { - opacity: 0; - z-index: 10; - - &--bottom-right { - position: absolute; - border-right: 1px solid ${theme.colors.text.label}; - border-bottom: 1px solid ${theme.colors.text.label}; - right: ${theme.gridUnit * 4}px; - bottom: ${theme.gridUnit * 4}px; - width: ${theme.gridUnit * 2}px; - height: ${theme.gridUnit * 2}px; - } - - &--right { - width: ${theme.gridUnit / 2}px; - height: ${theme.gridUnit * 5}px; - right: ${theme.gridUnit}px; - top: 50%; - transform: translate(0, -50%); - position: absolute; - border-left: 1px solid ${theme.colors.text.label}; - border-right: 1px solid ${theme.colors.text.label}; - } - - &--bottom { - height: ${theme.gridUnit / 2}px; - width: ${theme.gridUnit * 5}px; - bottom: ${theme.gridUnit}px; - left: 50%; - transform: translate(-50%); - position: absolute; - border-top: 1px solid ${theme.colors.text.label}; - border-bottom: 1px solid ${theme.colors.text.label}; - } - } - `} - - &.resizable-container:hover .resize-handle, - &.resizable-container--resizing .resize-handle { - opacity: 1; - } - - .dragdroppable-column & .resizable-container-handle--right { - /* override the default because the inner column's handle's mouse target is very small */ - right: 0 !important; - } - - & .resizable-container-handle--bottom { - bottom: 0 !important; - } -`; - -class ResizableContainer extends PureComponent { - constructor(props) { - super(props); - - this.state = { - isResizing: false, - }; - - this.handleResizeStart = this.handleResizeStart.bind(this); - this.handleResize = this.handleResize.bind(this); - this.handleResizeStop = this.handleResizeStop.bind(this); - } - - handleResizeStart(event, direction, ref) { - const { id, onResizeStart } = this.props; - - if (onResizeStart) { - onResizeStart({ id, direction, ref }); - } - - this.setState(() => ({ isResizing: true })); - } - - handleResize(event, direction, ref) { - const { onResize, id } = this.props; - if (onResize) { - onResize({ id, direction, ref }); - } - } - - handleResizeStop(event, direction, ref, delta) { - const { - id, - onResizeStop, - widthStep, - heightStep, - widthMultiple, - heightMultiple, - adjustableHeight, - adjustableWidth, - gutterWidth, - } = this.props; - - if (onResizeStop) { - const nextWidthMultiple = - widthMultiple + Math.round(delta.width / (widthStep + gutterWidth)); - const nextHeightMultiple = - heightMultiple + Math.round(delta.height / heightStep); - - onResizeStop({ - id, - widthMultiple: adjustableWidth ? nextWidthMultiple : null, - heightMultiple: adjustableHeight ? nextHeightMultiple : null, - }); - - this.setState(() => ({ isResizing: false })); - } - } - - render() { - const { - children, - adjustableWidth, - adjustableHeight, - widthStep, - heightStep, - widthMultiple, - heightMultiple, - staticHeight, - staticHeightMultiple, - staticWidth, - staticWidthMultiple, - minWidthMultiple, - maxWidthMultiple, - minHeightMultiple, - maxHeightMultiple, - gutterWidth, - editMode, - } = this.props; - - const size = { - width: adjustableWidth - ? (widthStep + gutterWidth) * widthMultiple - gutterWidth - : (staticWidthMultiple && staticWidthMultiple * widthStep) || - staticWidth || - undefined, - height: adjustableHeight - ? heightStep * heightMultiple - : (staticHeightMultiple && staticHeightMultiple * heightStep) || - staticHeight || - undefined, - }; - - let enableConfig = resizableConfig.notAdjustable; - - if (editMode && adjustableWidth && adjustableHeight) { - enableConfig = resizableConfig.widthAndHeight; - } else if (editMode && adjustableWidth) { - enableConfig = resizableConfig.widthOnly; - } else if (editMode && adjustableHeight) { - enableConfig = resizableConfig.heightOnly; - } - - const { isResizing } = this.state; - - return ( - - {children} - - ); - } -} - -ResizableContainer.propTypes = propTypes; -ResizableContainer.defaultProps = defaultProps; - -export default ResizableContainer; diff --git a/superset-frontend/src/dashboard/components/resizable/ResizableContainer.test.tsx b/superset-frontend/src/dashboard/components/resizable/ResizableContainer.test.tsx index 6c45c4c4fda51..37f9d072067d1 100644 --- a/superset-frontend/src/dashboard/components/resizable/ResizableContainer.test.tsx +++ b/superset-frontend/src/dashboard/components/resizable/ResizableContainer.test.tsx @@ -18,34 +18,17 @@ */ import { render } from 'spec/helpers/testing-library'; -import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer'; - -interface ResizableContainerProps { - id: string; - children?: object; - adjustableWidth?: boolean; - adjustableHeight?: boolean; - gutterWidth?: number; - widthStep?: number; - heightStep?: number; - widthMultiple?: number; - heightMultiple?: number; - minWidthMultiple?: number; - maxWidthMultiple?: number; - minHeightMultiple?: number; - maxHeightMultiple?: number; - staticHeight?: number; - staticHeightMultiple?: number; - staticWidth?: number; - staticWidthMultiple?: number; - onResizeStop?: () => {}; - onResize?: () => {}; - onResizeStart?: () => {}; - editMode: boolean; -} +import ResizableContainer, { + ResizableContainerProps, +} from 'src/dashboard/components/resizable/ResizableContainer'; describe('ResizableContainer', () => { - const props = { editMode: false, id: 'id' }; + const props = { + editMode: false, + id: 'id', + heightMultiple: 0, + widthMultiple: 0, + }; const setup = (overrides?: ResizableContainerProps) => ( diff --git a/superset-frontend/src/dashboard/components/resizable/ResizableContainer.tsx b/superset-frontend/src/dashboard/components/resizable/ResizableContainer.tsx new file mode 100644 index 0000000000000..318516366b1ae --- /dev/null +++ b/superset-frontend/src/dashboard/components/resizable/ResizableContainer.tsx @@ -0,0 +1,321 @@ +/** + * 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 { useState, useCallback, useMemo } from 'react'; +import { ResizeCallback, ResizeStartCallback, Resizable } from 're-resizable'; +import cx from 'classnames'; +import { css, styled } from '@superset-ui/core'; + +import { + RightResizeHandle, + BottomResizeHandle, + BottomRightResizeHandle, +} from './ResizableHandle'; +import resizableConfig from '../../util/resizableConfig'; +import { GRID_BASE_UNIT, GRID_GUTTER_SIZE } from '../../util/constants'; + +const proxyToInfinity = Number.MAX_VALUE; + +export interface ResizableContainerProps { + id: string; + children?: object; + adjustableWidth?: boolean; + adjustableHeight?: boolean; + gutterWidth?: number; + widthStep?: number; + heightStep?: number; + widthMultiple: number; + heightMultiple: number; + minWidthMultiple?: number; + maxWidthMultiple?: number; + minHeightMultiple?: number; + maxHeightMultiple?: number; + staticHeight?: number; + staticHeightMultiple?: number; + staticWidth?: number; + staticWidthMultiple?: number; + onResizeStart?: ResizeStartCallback; + onResize?: ResizeCallback; + onResizeStop?: ResizeCallback; + editMode: boolean; +} + +// because columns are not multiples of a single variable (width = n*cols + (n-1) * gutters) +// we snap to the base unit and then snap to _actual_ column multiples on stop +const SNAP_TO_GRID: [number, number] = [GRID_BASE_UNIT, GRID_BASE_UNIT]; +const HANDLE_CLASSES = { + right: 'resizable-container-handle--right', + bottom: 'resizable-container-handle--bottom', +}; +// @ts-ignore +const StyledResizable = styled(Resizable)` + ${({ theme }) => css` + &.resizable-container { + background-color: transparent; + position: relative; + + /* re-resizable sets an empty div to 100% width and height, which doesn't + play well with many 100% height containers we need */ + + & ~ div { + width: auto !important; + height: auto !important; + } + } + + &.resizable-container--resizing { + /* after ensures border visibility on top of any children */ + + &:after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-shadow: inset 0 0 0 2px ${theme.colors.primary.base}; + } + + & > span .resize-handle { + border-color: ${theme.colors.primary.base}; + } + } + + .resize-handle { + opacity: 0; + z-index: 10; + + &--bottom-right { + position: absolute; + border-right: 1px solid ${theme.colors.text.label}; + border-bottom: 1px solid ${theme.colors.text.label}; + right: ${theme.gridUnit * 4}px; + bottom: ${theme.gridUnit * 4}px; + width: ${theme.gridUnit * 2}px; + height: ${theme.gridUnit * 2}px; + } + + &--right { + width: ${theme.gridUnit / 2}px; + height: ${theme.gridUnit * 5}px; + right: ${theme.gridUnit}px; + top: 50%; + transform: translate(0, -50%); + position: absolute; + border-left: 1px solid ${theme.colors.text.label}; + border-right: 1px solid ${theme.colors.text.label}; + } + + &--bottom { + height: ${theme.gridUnit / 2}px; + width: ${theme.gridUnit * 5}px; + bottom: ${theme.gridUnit}px; + left: 50%; + transform: translate(-50%); + position: absolute; + border-top: 1px solid ${theme.colors.text.label}; + border-bottom: 1px solid ${theme.colors.text.label}; + } + } + `} + + &.resizable-container:hover .resize-handle, + &.resizable-container--resizing .resize-handle { + opacity: 1; + } + + .dragdroppable-column & .resizable-container-handle--right { + /* override the default because the inner column's handle's mouse target is very small */ + right: 0 !important; + } + + & .resizable-container-handle--bottom { + bottom: 0 !important; + } +`; + +export default function ResizableContainer({ + id, + children, + widthMultiple, + heightMultiple, + staticHeight, + staticHeightMultiple, + staticWidth, + staticWidthMultiple, + onResizeStop, + onResize, + onResizeStart, + editMode, + adjustableWidth = true, + adjustableHeight = true, + gutterWidth = GRID_GUTTER_SIZE, + widthStep = GRID_BASE_UNIT, + heightStep = GRID_BASE_UNIT, + minWidthMultiple = 1, + maxWidthMultiple = proxyToInfinity, + minHeightMultiple = 1, + maxHeightMultiple = proxyToInfinity, +}: ResizableContainerProps) { + const [isResizing, setIsResizing] = useState(false); + + const handleResize = useCallback( + (event, direction, elementRef, delta) => { + if (onResize) onResize(event, direction, elementRef, delta); + }, + [onResize], + ); + + const handleResizeStart = useCallback( + (e, dir, elementRef) => { + if (onResizeStart) onResizeStart(e, dir, elementRef); + setIsResizing(true); + }, + [onResizeStart], + ); + + const handleResizeStop = useCallback( + (event, direction, elementRef, delta) => { + if (onResizeStop) { + const nextWidthMultiple = + widthMultiple + Math.round(delta.width / (widthStep + gutterWidth)); + const nextHeightMultiple = + heightMultiple + Math.round(delta.height / heightStep); + + onResizeStop( + event, + direction, + elementRef, + { + width: adjustableWidth ? nextWidthMultiple : 0, + height: adjustableHeight ? nextHeightMultiple : 0, + }, + // @ts-ignore + id, + ); + } + setIsResizing(false); + }, + [ + onResizeStop, + widthMultiple, + heightMultiple, + widthStep, + heightStep, + gutterWidth, + adjustableWidth, + adjustableHeight, + id, + ], + ); + + const size = useMemo( + () => ({ + width: adjustableWidth + ? (widthStep + gutterWidth) * widthMultiple - gutterWidth + : (staticWidthMultiple && staticWidthMultiple * widthStep) || + staticWidth || + undefined, + height: adjustableHeight + ? heightStep * heightMultiple + : (staticHeightMultiple && staticHeightMultiple * heightStep) || + staticHeight || + undefined, + }), + [ + adjustableWidth, + widthStep, + gutterWidth, + widthMultiple, + staticWidthMultiple, + staticWidth, + adjustableHeight, + heightStep, + heightMultiple, + staticHeightMultiple, + staticHeight, + ], + ); + + const handleComponent = useMemo( + () => ({ + right: , + bottom: , + bottomRight: , + }), + [], + ); + + const enableConfig = useMemo(() => { + if (editMode && adjustableWidth && adjustableHeight) { + return resizableConfig.widthAndHeight; + } + if (editMode && adjustableWidth) { + return resizableConfig.widthOnly; + } + if (editMode && adjustableHeight) { + return resizableConfig.heightOnly; + } + return resizableConfig.notAdjustable; + }, [editMode, adjustableWidth, adjustableHeight]); + + return ( + + {children} + + ); +} diff --git a/superset-frontend/src/dashboard/components/resizable/ResizableHandle.jsx b/superset-frontend/src/dashboard/components/resizable/ResizableHandle.tsx similarity index 87% rename from superset-frontend/src/dashboard/components/resizable/ResizableHandle.jsx rename to superset-frontend/src/dashboard/components/resizable/ResizableHandle.tsx index 5d8e4894de397..5365703a42f71 100644 --- a/superset-frontend/src/dashboard/components/resizable/ResizableHandle.jsx +++ b/superset-frontend/src/dashboard/components/resizable/ResizableHandle.tsx @@ -16,15 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -export function BottomRightResizeHandle() { +export function BottomRightResizeHandle(): JSX.Element { return
; } -export function RightResizeHandle() { +export function RightResizeHandle(): JSX.Element { return
; } -export function BottomResizeHandle() { +export function BottomResizeHandle(): JSX.Element { return
; }