refactor: Migrate SliceAdder to typescript (#30697)

This commit is contained in:
Enzo Martellucci 2024-10-31 15:57:49 +01:00 committed by GitHub
parent 58edc79820
commit 31aad28a31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 269 additions and 132 deletions

View File

@ -17,6 +17,7 @@
* under the License.
*/
import { datasourceId } from 'spec/fixtures/mockDatasource';
import { DatasourceType } from '@superset-ui/core';
import { sliceId } from './mockChartQueries';
export const filterId = 127;
@ -47,8 +48,8 @@ export const sliceEntitiesForChart = {
},
viz_type: 'pie',
datasource: datasourceId,
description: null,
description_markeddown: '',
description: '',
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332615,
},
@ -79,10 +80,18 @@ export const sliceEntitiesForDashboard = {
},
viz_type: 'filter_box',
datasource: '2__table',
description: null,
description_markeddown: '',
description: '',
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332615,
changed_on_humanized: '',
datasource_id: 0,
datasource_type: DatasourceType.Query,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
created_by: { id: 0 },
thumbnail_url: '',
},
128: {
slice_id: 128,
@ -91,10 +100,18 @@ export const sliceEntitiesForDashboard = {
form_data: {},
viz_type: 'big_number',
datasource: '2__table',
description: null,
description_markeddown: '',
description: '',
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332628,
changed_on_humanized: '',
datasource_id: 0,
datasource_type: DatasourceType.Query,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
created_by: { id: 0 },
thumbnail_url: '',
},
129: {
slice_id: 129,
@ -103,10 +120,19 @@ export const sliceEntitiesForDashboard = {
form_data: {},
viz_type: 'table',
datasource: '2__table',
description: null,
description_markeddown: '',
description: '',
description_markdown: 'dd',
modified: '23 hours ago',
changed_on: 1529453332637,
changed_on_humanized: '',
datasource_id: 0,
datasource_type: DatasourceType.Query,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
created_by: { id: 0 },
thumbnail_url: '',
},
130: {
slice_id: 130,
@ -115,10 +141,19 @@ export const sliceEntitiesForDashboard = {
form_data: {},
viz_type: 'line',
datasource: '2__table',
description: null,
description_markeddown: '',
description: '',
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332645,
changed_on_humanized: '',
datasource_id: 0,
datasource_type: DatasourceType.SlTable,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
created_by: { id: 0 },
thumbnail_url: '',
},
131: {
slice_id: 131,
@ -127,10 +162,19 @@ export const sliceEntitiesForDashboard = {
form_data: {},
viz_type: 'world_map',
datasource: '2__table',
description: null,
description_markeddown: '',
description: '',
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332654,
changed_on_humanized: '',
datasource_id: 0,
datasource_type: DatasourceType.Table,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
created_by: { id: 0 },
thumbnail_url: '',
},
132: {
slice_id: 132,
@ -139,10 +183,19 @@ export const sliceEntitiesForDashboard = {
form_data: {},
viz_type: 'bubble',
datasource: '2__table',
description: null,
description_markeddown: '',
description: '',
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332663,
changed_on_humanized: '',
datasource_id: 0,
datasource_type: DatasourceType.Query,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
created_by: { id: 0 },
thumbnail_url: '',
},
133: {
slice_id: 133,
@ -151,10 +204,19 @@ export const sliceEntitiesForDashboard = {
form_data: {},
viz_type: 'sunburst_v2',
datasource: '2__table',
description: null,
description_markeddown: '',
description: '',
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332673,
changed_on_humanized: '',
datasource_id: 0,
datasource_type: DatasourceType.Query,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
created_by: { id: 0 },
thumbnail_url: '',
},
134: {
slice_id: 134,
@ -163,10 +225,19 @@ export const sliceEntitiesForDashboard = {
form_data: {},
viz_type: 'area',
datasource: '2__table',
description: null,
description_markeddown: '',
description: '',
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332680,
changed_on_humanized: '',
datasource_id: 0,
datasource_type: DatasourceType.Dataset,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
created_by: { id: 0 },
thumbnail_url: '',
},
135: {
slice_id: 135,
@ -175,10 +246,19 @@ export const sliceEntitiesForDashboard = {
form_data: {},
viz_type: 'box_plot',
datasource: '2__table',
description: null,
description_markeddown: '',
description: '',
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332688,
changed_on_humanized: '',
datasource_id: 0,
datasource_type: DatasourceType.Table,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
created_by: { id: 0 },
thumbnail_url: '',
},
136: {
slice_id: 136,
@ -187,10 +267,19 @@ export const sliceEntitiesForDashboard = {
form_data: {},
viz_type: 'treemap_v2',
datasource: '2__table',
description: null,
description_markeddown: '',
description: '',
description_markdown: '',
modified: '23 hours ago',
changed_on: 1529453332700,
changed_on_humanized: '',
datasource_id: 0,
datasource_type: DatasourceType.Table,
datasource_url: '',
datasource_name: '',
owners: [{ id: 0 }],
created_by: { id: 0 },
thumbnail_url: '',
},
},
isLoading: false,

View File

@ -16,35 +16,45 @@
* specific language governing permissions and limitations
* under the License.
*/
import { shallow } from 'enzyme';
import { shallow, ShallowWrapper } from 'enzyme';
import sinon from 'sinon';
import SliceAdder, {
ChartList,
DEFAULT_SORT_KEY,
SliceAdderProps,
} from 'src/dashboard/components/SliceAdder';
import { sliceEntitiesForDashboard as mockSliceEntities } from 'spec/fixtures/mockSliceEntities';
import { styledShallow } from 'spec/helpers/theming';
jest.mock('lodash/debounce', () => fn => {
// eslint-disable-next-line no-param-reassign
fn.throttle = jest.fn();
return fn;
});
jest.mock(
'lodash/debounce',
() => (fn: { throttle: jest.Mock<any, any, any> }) => {
// eslint-disable-next-line no-param-reassign
fn.throttle = jest.fn();
return fn;
},
);
describe('SliceAdder', () => {
const props = {
...mockSliceEntities,
const props: SliceAdderProps = {
slices: {
...mockSliceEntities.slices,
},
fetchSlices: jest.fn(),
updateSlices: jest.fn(),
selectedSliceIds: [127, 128],
userId: 1,
dashboardId: 0,
editMode: false,
errorMessage: '',
isLoading: false,
lastUpdated: 0,
};
const errorProps = {
...props,
errorMessage: 'this is error',
};
describe('SliceAdder.sortByComparator', () => {
it('should sort by timestamp descending', () => {
const sortedTimestamps = Object.values(props.slices)
@ -84,72 +94,88 @@ describe('SliceAdder', () => {
});
it('componentDidMount', () => {
sinon.spy(SliceAdder.prototype, 'componentDidMount');
sinon.spy(props, 'fetchSlices');
const componentDidMountSpy = sinon.spy(
SliceAdder.prototype,
'componentDidMount',
);
const fetchSlicesSpy = sinon.spy(props, 'fetchSlices');
shallow(<SliceAdder {...props} />, {
lifecycleExperimental: true,
});
expect(SliceAdder.prototype.componentDidMount.calledOnce).toBe(true);
expect(props.fetchSlices.calledOnce).toBe(true);
SliceAdder.prototype.componentDidMount.restore();
props.fetchSlices.restore();
expect(componentDidMountSpy.calledOnce).toBe(true);
expect(fetchSlicesSpy.calledOnce).toBe(true);
componentDidMountSpy.restore();
fetchSlicesSpy.restore();
});
describe('UNSAFE_componentWillReceiveProps', () => {
let wrapper;
let wrapper: ShallowWrapper;
let setStateSpy: sinon.SinonSpy;
beforeEach(() => {
wrapper = shallow(<SliceAdder {...props} />);
wrapper.setState({ filteredSlices: Object.values(props.slices) });
sinon.spy(wrapper.instance(), 'setState');
setStateSpy = sinon.spy(wrapper.instance() as SliceAdder, 'setState');
});
afterEach(() => {
wrapper.instance().setState.restore();
setStateSpy.restore();
});
it('fetch slices should update state', () => {
wrapper.instance().UNSAFE_componentWillReceiveProps({
const instance = wrapper.instance() as SliceAdder;
instance.UNSAFE_componentWillReceiveProps({
...props,
lastUpdated: new Date().getTime(),
});
expect(wrapper.instance().setState.calledOnce).toBe(true);
expect(setStateSpy.calledOnce).toBe(true);
const stateKeys = Object.keys(
wrapper.instance().setState.lastCall.args[0],
);
const stateKeys = Object.keys(setStateSpy.lastCall.args[0]);
expect(stateKeys).toContain('filteredSlices');
});
it('select slices should update state', () => {
wrapper.instance().UNSAFE_componentWillReceiveProps({
const instance = wrapper.instance() as SliceAdder;
instance.UNSAFE_componentWillReceiveProps({
...props,
selectedSliceIds: [127],
});
expect(wrapper.instance().setState.calledOnce).toBe(true);
const stateKeys = Object.keys(
wrapper.instance().setState.lastCall.args[0],
);
expect(setStateSpy.calledOnce).toBe(true);
const stateKeys = Object.keys(setStateSpy.lastCall.args[0]);
expect(stateKeys).toContain('selectedSliceIdsSet');
});
});
describe('should rerun filter and sort', () => {
let wrapper;
let spy;
let wrapper: ShallowWrapper<SliceAdder>;
let spy: jest.Mock;
beforeEach(() => {
spy = props.fetchSlices;
wrapper = shallow(<SliceAdder {...props} fetchSlices={spy} />);
wrapper.setState({ filteredSlices: Object.values(props.slices) });
spy = jest.fn();
const fetchSlicesProps: SliceAdderProps = {
...props,
fetchSlices: spy,
};
wrapper = shallow(<SliceAdder {...fetchSlicesProps} />);
wrapper.setState({
filteredSlices: Object.values(fetchSlicesProps.slices),
});
});
afterEach(() => {
spy.mockReset();
});
it('searchUpdated', () => {
const newSearchTerm = 'new search term';
wrapper.instance().handleChange(newSearchTerm);
(wrapper.instance() as SliceAdder).handleChange(newSearchTerm);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(
props.userId,
@ -160,7 +186,9 @@ describe('SliceAdder', () => {
it('handleSelect', () => {
const newSortBy = 'viz_type';
wrapper.instance().handleSelect(newSortBy);
(wrapper.instance() as SliceAdder).handleSelect(newSortBy);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(props.userId, '', newSortBy);
});

View File

@ -18,9 +18,9 @@
*/
/* eslint-env browser */
import { Component } from 'react';
import PropTypes from 'prop-types';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
// @ts-ignore
import { createFilter } from 'react-search-input';
import { t, styled, css } from '@superset-ui/core';
import { Input } from 'src/components/Input';
@ -41,31 +41,40 @@ import {
NEW_CHART_ID,
NEW_COMPONENTS_SOURCE_ID,
} from 'src/dashboard/util/constants';
import { slicePropShape } from 'src/dashboard/util/propShapes';
import { debounce, pickBy } from 'lodash';
import Checkbox from 'src/components/Checkbox';
import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
import { Dispatch } from 'redux';
import { Slice } from 'src/dashboard/types';
import AddSliceCard from './AddSliceCard';
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
import DragDroppable from './dnd/DragDroppable';
const propTypes = {
fetchSlices: PropTypes.func.isRequired,
updateSlices: PropTypes.func.isRequired,
isLoading: PropTypes.bool.isRequired,
slices: PropTypes.objectOf(slicePropShape).isRequired,
lastUpdated: PropTypes.number.isRequired,
errorMessage: PropTypes.string,
userId: PropTypes.number.isRequired,
selectedSliceIds: PropTypes.arrayOf(PropTypes.number),
editMode: PropTypes.bool,
dashboardId: PropTypes.number,
export type SliceAdderProps = {
fetchSlices: (
userId?: number,
filter_value?: string,
sortColumn?: string,
) => Promise<void>;
updateSlices: (slices: {
[id: number]: Slice;
}) => (dispatch: Dispatch) => void;
isLoading: boolean;
slices: Record<number, Slice>;
lastUpdated: number;
errorMessage?: string;
userId: number;
selectedSliceIds?: number[];
editMode?: boolean;
dashboardId: number;
};
const defaultProps = {
selectedSliceIds: [],
editMode: false,
errorMessage: '',
type SliceAdderState = {
filteredSlices: Slice[];
searchTerm: string;
sortBy: keyof Slice;
selectedSliceIdsSet: Set<number>;
showOnlyMyCharts: boolean;
};
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
@ -92,7 +101,7 @@ const Controls = styled.div`
`}
`;
const StyledSelect = styled(Select)`
const StyledSelect = styled(Select)<{ id?: string }>`
margin-left: ${({ theme }) => theme.gridUnit * 2}px;
min-width: 150px;
`;
@ -124,22 +133,33 @@ export const ChartList = styled.div`
min-height: 0;
`;
class SliceAdder extends Component {
static sortByComparator(attr) {
class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
private slicesRequest?: AbortController | Promise<void>;
static sortByComparator(attr: keyof Slice) {
const desc = attr === 'changed_on' ? -1 : 1;
return (a, b) => {
if (a[attr] < b[attr]) {
return (a: Slice, b: Slice) => {
const aValue = a[attr] ?? Number.MIN_SAFE_INTEGER;
const bValue = b[attr] ?? Number.MIN_SAFE_INTEGER;
if (aValue < bValue) {
return -1 * desc;
}
if (a[attr] > b[attr]) {
if (aValue > bValue) {
return 1 * desc;
}
return 0;
};
}
constructor(props) {
static defaultProps = {
selectedSliceIds: [],
editMode: false,
errorMessage: '',
};
constructor(props: SliceAdderProps) {
super(props);
this.state = {
filteredSlices: [],
@ -163,11 +183,15 @@ class SliceAdder extends Component {
}
componentDidMount() {
this.slicesRequest = this.props.fetchSlices(this.userIdForFetch());
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
'',
this.state.sortBy,
);
}
UNSAFE_componentWillReceiveProps(nextProps) {
const nextState = {};
UNSAFE_componentWillReceiveProps(nextProps: SliceAdderProps) {
const nextState: SliceAdderState = {} as SliceAdderState;
if (nextProps.lastUpdated !== this.props.lastUpdated) {
nextState.filteredSlices = this.getFilteredSortedSlices(
nextProps.slices,
@ -188,22 +212,27 @@ class SliceAdder extends Component {
componentWillUnmount() {
// Clears the redux store keeping only selected items
const selectedSlices = pickBy(this.props.slices, value =>
const selectedSlices = pickBy(this.props.slices, (value: Slice) =>
this.state.selectedSliceIdsSet.has(value.slice_id),
);
this.props.updateSlices(selectedSlices);
if (this.slicesRequest && this.slicesRequest.abort) {
if (this.slicesRequest instanceof AbortController) {
this.slicesRequest.abort();
}
}
getFilteredSortedSlices(slices, searchTerm, sortBy, showOnlyMyCharts) {
getFilteredSortedSlices(
slices: SliceAdderProps['slices'],
searchTerm: string,
sortBy: keyof Slice,
showOnlyMyCharts: boolean,
) {
return Object.values(slices)
.filter(slice =>
showOnlyMyCharts
? (slice.owners &&
slice.owners.find(owner => owner.id === this.props.userId)) ||
(slice.created_by && slice.created_by.id === this.props.userId)
? slice?.owners?.find(owner => owner.id === this.props.userId) ||
slice?.created_by?.id === this.props.userId
: true,
)
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
@ -219,7 +248,7 @@ class SliceAdder extends Component {
);
}, 300);
searchUpdated(searchTerm) {
searchUpdated(searchTerm: string) {
this.setState(prevState => ({
searchTerm,
filteredSlices: this.getFilteredSortedSlices(
@ -231,7 +260,7 @@ class SliceAdder extends Component {
}));
}
handleSelect(sortBy) {
handleSelect(sortBy: keyof Slice) {
this.setState(prevState => ({
sortBy,
filteredSlices: this.getFilteredSortedSlices(
@ -248,9 +277,10 @@ class SliceAdder extends Component {
);
}
rowRenderer({ key, index, style }) {
rowRenderer({ index, style }: { index: number; style: React.CSSProperties }) {
const { filteredSlices, selectedSliceIdsSet } = this.state;
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
@ -261,7 +291,7 @@ class SliceAdder extends Component {
};
return (
<DragDroppable
key={key}
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
@ -295,7 +325,7 @@ class SliceAdder extends Component {
);
}
onShowOnlyMyCharts(showOnlyMyCharts) {
onShowOnlyMyCharts(showOnlyMyCharts: boolean) {
if (!showOnlyMyCharts) {
this.slicesRequest = this.props.fetchSlices(
undefined,
@ -390,15 +420,13 @@ class SliceAdder extends Component {
{!this.props.isLoading && this.state.filteredSlices.length > 0 && (
<ChartList>
<AutoSizer>
{({ height, width }) => (
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={this.state.filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
searchTerm={this.state.searchTerm}
sortBy={this.state.sortBy}
selectedSliceIds={this.props.selectedSliceIds}
itemKey={index => this.state.filteredSlices[index].slice_id}
>
{this.rowRenderer}
</List>
@ -422,7 +450,4 @@ class SliceAdder extends Component {
}
}
SliceAdder.propTypes = propTypes;
SliceAdder.defaultProps = defaultProps;
export default SliceAdder;

View File

@ -16,17 +16,28 @@
* specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
import { DragLayer } from 'react-dnd';
import { DragLayer, XYCoord } from 'react-dnd';
import { Slice } from 'src/dashboard/types';
import AddSliceCard from '../AddSliceCard';
import { slicePropShape } from '../../util/propShapes';
import {
NEW_COMPONENT_SOURCE_TYPE,
CHART_TYPE,
} from '../../util/componentTypes';
const staticCardStyles = {
interface DragItem {
index: number;
parentType: string;
type: string;
}
interface AddSliceDragPreviewProps {
dragItem: DragItem | null;
slices: Slice[] | null;
isDragging: boolean;
currentOffset: XYCoord | null;
}
const staticCardStyles: React.CSSProperties = {
position: 'fixed',
pointerEvents: 'none',
top: 0,
@ -35,25 +46,12 @@ const staticCardStyles = {
width: 376 - 2 * 16,
};
const propTypes = {
dragItem: PropTypes.shape({
index: PropTypes.number.isRequired,
}),
slices: PropTypes.arrayOf(slicePropShape),
isDragging: PropTypes.bool.isRequired,
currentOffset: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
}),
};
const defaultProps = {
currentOffset: null,
dragItem: null,
slices: null,
};
function AddSliceDragPreview({ dragItem, slices, isDragging, currentOffset }) {
const AddSliceDragPreview: React.FC<AddSliceDragPreviewProps> = ({
dragItem,
slices,
isDragging,
currentOffset,
}) => {
if (!isDragging || !currentOffset || !dragItem || !slices) return null;
const slice = slices[dragItem.index];
@ -77,14 +75,11 @@ function AddSliceDragPreview({ dragItem, slices, isDragging, currentOffset }) {
datasourceName={slice.datasource_name}
/>
);
}
AddSliceDragPreview.propTypes = propTypes;
AddSliceDragPreview.defaultProps = defaultProps;
};
// This injects these props into the component
export default DragLayer(monitor => ({
dragItem: monitor.getItem(),
dragItem: monitor.getItem() as DragItem | null,
currentOffset: monitor.getSourceClientOffset(),
isDragging: monitor.isDragging(),
}))(AddSliceDragPreview);