chore: refactor ChartHolder to typescript + tests (#20910)

This commit is contained in:
Diego Medina 2022-09-05 06:43:53 -03:00 committed by GitHub
parent d994babe75
commit b71182f013
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1005 additions and 574 deletions

View File

@ -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,

View File

@ -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() {

View File

@ -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);

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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;

View File

@ -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);
});
});

View File

@ -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;