| /** |
| * 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 { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters'; |
| import Chart from '../../containers/Chart'; |
| import AnchorLink from '../../../components/AnchorLink'; |
| import DeleteComponentButton from '../DeleteComponentButton'; |
| import DragDroppable from '../dnd/DragDroppable'; |
| import HoverMenu from '../menu/HoverMenu'; |
| import ResizableContainer from '../resizable/ResizableContainer'; |
| import getChartAndLabelComponentIdFromPath from '../../util/getChartAndLabelComponentIdFromPath'; |
| import { componentShape } from '../../util/propShapes'; |
| import { COLUMN_TYPE, ROW_TYPE } from '../../util/componentTypes'; |
| |
| import { |
| GRID_BASE_UNIT, |
| GRID_GUTTER_SIZE, |
| GRID_MIN_COLUMN_COUNT, |
| GRID_MIN_ROW_UNITS, |
| } from '../../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, |
| }; |
| |
| 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 <div ref={ref} {...otherProps} />; |
| |
| // 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 <div ref={ref} style={focusedChartStyles} {...otherProps} />; |
| } |
| } else if ( |
| chartId === focusedFilterScope.chartId || |
| getChartIdsInFilterScope({ |
| filterScope: focusedFilterScope.scope, |
| }).includes(chartId) |
| ) { |
| return <div ref={ref} style={focusedChartStyles} {...otherProps} />; |
| } |
| |
| // inline styles are used here due to a performance issue when adding/changing a class, which causes a reflow |
| return <div ref={ref} style={unfocusedChartStyles} {...otherProps} />; |
| }, |
| ); |
| |
| class ChartHolder extends React.Component { |
| static renderInFocusCSS(columnName) { |
| return ( |
| <style> |
| {`label[for=${columnName}] + .Select .Select__control { |
| border-color: #00736a; |
| transition: border-color 1s ease-in-out; |
| }`} |
| </style> |
| ); |
| } |
| |
| 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, |
| }; |
| |
| this.handleChangeFocus = this.handleChangeFocus.bind(this); |
| this.handleDeleteComponent = this.handleDeleteComponent.bind(this); |
| this.handleUpdateSliceName = this.handleUpdateSliceName.bind(this); |
| this.handleToggleFullSize = this.handleToggleFullSize.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); |
| } |
| |
| render() { |
| const { isFocused } = 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 ( |
| <DragDroppable |
| component={component} |
| parentComponent={parentComponent} |
| orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'} |
| index={index} |
| depth={depth} |
| onDrop={handleComponentDrop} |
| disableDragDrop={isFocused} |
| editMode={editMode} |
| > |
| {({ dropIndicatorProps, dragSourceRef }) => ( |
| <ResizableContainer |
| id={component.id} |
| adjustableWidth={parentComponent.type === ROW_TYPE} |
| adjustableHeight |
| widthStep={columnWidth} |
| widthMultiple={widthMultiple} |
| heightStep={GRID_BASE_UNIT} |
| heightMultiple={component.meta.height} |
| minWidthMultiple={GRID_MIN_COLUMN_COUNT} |
| minHeightMultiple={GRID_MIN_ROW_UNITS} |
| maxWidthMultiple={availableColumnCount + widthMultiple} |
| onResizeStart={onResizeStart} |
| onResize={onResize} |
| onResizeStop={onResizeStop} |
| editMode={editMode} |
| > |
| <FilterFocusHighlight |
| chartId={chartId} |
| ref={dragSourceRef} |
| data-test="dashboard-component-chart-holder" |
| className={cx( |
| 'dashboard-component', |
| 'dashboard-component-chart-holder', |
| this.state.outlinedComponentId ? 'fade-in' : 'fade-out', |
| isFullSize && 'full-size', |
| )} |
| > |
| {!editMode && ( |
| <AnchorLink |
| anchorLinkId={component.id} |
| inFocus={!!this.state.outlinedComponentId} |
| /> |
| )} |
| {!!this.state.outlinedComponentId && |
| ChartHolder.renderInFocusCSS(this.state.outlinedColumnName)} |
| <Chart |
| componentId={component.id} |
| id={component.meta.chartId} |
| dashboardId={dashboardId} |
| width={chartWidth} |
| height={chartHeight} |
| sliceName={ |
| component.meta.sliceNameOverride || |
| component.meta.sliceName || |
| '' |
| } |
| updateSliceName={this.handleUpdateSliceName} |
| isComponentVisible={isComponentVisible} |
| handleToggleFullSize={this.handleToggleFullSize} |
| isFullSize={isFullSize} |
| /> |
| {editMode && ( |
| <HoverMenu position="top"> |
| <div data-test="dashboard-delete-component-button"> |
| <DeleteComponentButton |
| onDelete={this.handleDeleteComponent} |
| /> |
| </div> |
| </HoverMenu> |
| )} |
| </FilterFocusHighlight> |
| |
| {dropIndicatorProps && <div {...dropIndicatorProps} />} |
| </ResizableContainer> |
| )} |
| </DragDroppable> |
| ); |
| } |
| } |
| |
| ChartHolder.propTypes = propTypes; |
| ChartHolder.defaultProps = defaultProps; |
| |
| function mapStateToProps(state) { |
| return { |
| directPathToChild: state.dashboardState.directPathToChild, |
| directPathLastUpdated: state.dashboardState.directPathLastUpdated, |
| }; |
| } |
| export default connect(mapStateToProps)(ChartHolder); |