blob: 9d447674d1a1a918de559764bcb4b6ac09eaa3b0 [file] [log] [blame]
/**
* 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);