mirror of
https://github.com/apache/superset.git
synced 2024-09-16 10:39:55 -04:00
chore: refactor ChartHolder to typescript + tests (#20910)
This commit is contained in:
parent
d994babe75
commit
b71182f013
@ -26,9 +26,10 @@ import getLocationHash from 'src/dashboard/util/getLocationHash';
|
||||
|
||||
export type AnchorLinkProps = {
|
||||
id: string;
|
||||
dashboardId?: number;
|
||||
scrollIntoView?: boolean;
|
||||
showShortLinkButton?: boolean;
|
||||
} & Pick<URLShortLinkButtonProps, 'dashboardId' | 'placement'>;
|
||||
} & Pick<URLShortLinkButtonProps, 'placement'>;
|
||||
|
||||
export default function AnchorLink({
|
||||
id,
|
||||
|
@ -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() {
|
||||
|
@ -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 <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 ||
|
||||
getChartIdsInFilterBoxScope({
|
||||
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,
|
||||
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 (
|
||||
<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',
|
||||
// The following class is added to support custom dashboard styling via the CSS editor
|
||||
`dashboard-chart-id-${chartId}`,
|
||||
this.state.outlinedComponentId ? 'fade-in' : 'fade-out',
|
||||
isFullSize && 'full-size',
|
||||
)}
|
||||
>
|
||||
{!editMode && (
|
||||
<AnchorLink
|
||||
id={component.id}
|
||||
scrollIntoView={
|
||||
this.state.outlinedComponentId === component.id
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!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}
|
||||
setControlValue={this.handleExtraControl}
|
||||
extraControls={extraControls}
|
||||
postTransformProps={this.handlePostTransformProps}
|
||||
/>
|
||||
{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);
|
@ -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(
|
||||
<Provider store={mockStore}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ChartHolderConnected {...props} {...overrideProps} />
|
||||
</DndProvider>
|
||||
</Provider>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
@ -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(<ChartHolder {...defaultProps} />, {
|
||||
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(<ChartHolder {...defaultProps} {...props} />, {
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<ChartHolder {...defaultProps} editMode />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<ChartHolder
|
||||
{...defaultProps}
|
||||
deleteComponent={deleteComponent}
|
||||
fullSizeChartId={null}
|
||||
editMode
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -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<ChartHolderProps> = ({
|
||||
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<Record<string, unknown>>(
|
||||
{},
|
||||
);
|
||||
const [outlinedComponentId, setOutlinedComponentId] = useState<string>();
|
||||
const [outlinedColumnName, setOutlinedColumnName] = useState<string>();
|
||||
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 (
|
||||
<DragDroppable
|
||||
component={component}
|
||||
parentComponent={parentComponent}
|
||||
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
|
||||
index={index}
|
||||
depth={depth}
|
||||
onDrop={handleComponentDrop}
|
||||
disableDragDrop={false}
|
||||
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}
|
||||
>
|
||||
<div
|
||||
ref={dragSourceRef}
|
||||
data-test="dashboard-component-chart-holder"
|
||||
style={focusHighlightStyles}
|
||||
className={cx(
|
||||
'dashboard-component',
|
||||
'dashboard-component-chart-holder',
|
||||
// The following class is added to support custom dashboard styling via the CSS editor
|
||||
`dashboard-chart-id-${chartId}`,
|
||||
outlinedComponentId ? 'fade-in' : 'fade-out',
|
||||
isFullSize && 'full-size',
|
||||
)}
|
||||
>
|
||||
{!editMode && (
|
||||
<AnchorLink
|
||||
id={component.id}
|
||||
scrollIntoView={outlinedComponentId === component.id}
|
||||
/>
|
||||
)}
|
||||
{!!outlinedComponentId && (
|
||||
<style>
|
||||
{`label[for=${outlinedColumnName}] + .Select .Select__control {
|
||||
border-color: #00736a;
|
||||
transition: border-color 1s ease-in-out;
|
||||
}`}
|
||||
</style>
|
||||
)}
|
||||
<Chart
|
||||
componentId={component.id}
|
||||
id={component.meta.chartId}
|
||||
dashboardId={dashboardId}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
sliceName={
|
||||
component.meta.sliceNameOverride ||
|
||||
component.meta.sliceName ||
|
||||
''
|
||||
}
|
||||
updateSliceName={handleUpdateSliceName}
|
||||
isComponentVisible={isComponentVisible}
|
||||
handleToggleFullSize={handleToggleFullSize}
|
||||
isFullSize={isFullSize}
|
||||
setControlValue={handleExtraControl}
|
||||
extraControls={extraControls}
|
||||
postTransformProps={handlePostTransformProps}
|
||||
/>
|
||||
{editMode && (
|
||||
<HoverMenu position="top">
|
||||
<div data-test="dashboard-delete-component-button">
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
</div>
|
||||
</HoverMenu>
|
||||
)}
|
||||
</div>
|
||||
{dropIndicatorProps && <div {...dropIndicatorProps} />}
|
||||
</ResizableContainer>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartHolder;
|
@ -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;
|
||||
|
@ -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 <div data-test="test-component" style={styles} />;
|
||||
};
|
||||
|
||||
describe('useFilterFocusHighlightStyles', () => {
|
||||
const createMockStore = (customState: any = {}) =>
|
||||
createStore(
|
||||
combineReducers(reducerIndex),
|
||||
{ ...mockState, ...(initialState as any), ...customState },
|
||||
compose(applyMiddleware(thunk)),
|
||||
);
|
||||
|
||||
const renderWrapper = (chartId: number, store = createMockStore()) =>
|
||||
render(<TestComponent chartId={chartId} />, {
|
||||
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);
|
||||
});
|
||||
});
|
@ -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;
|
Loading…
Reference in New Issue
Block a user