refactor(explore): convert ControlPanelsContainer to typescript (#13221)

This commit is contained in:
Jesse Yang
2021-02-28 08:10:15 -10:00
committed by GitHub
parent 892eef1af6
commit 3c62069bbb
27 changed files with 385 additions and 326 deletions

View File

@@ -18,30 +18,47 @@
*/
/* eslint camelcase: 0 */
import React from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { t, styled, getChartControlPanelRegistry } from '@superset-ui/core';
import {
t,
styled,
getChartControlPanelRegistry,
QueryFormData,
DatasourceType,
} from '@superset-ui/core';
import Tabs from 'src/common/components/Tabs';
import Alert from 'src/components/Alert';
import Collapse from 'src/common/components/Collapse';
import { PluginContext } from 'src/components/DynamicPlugins';
import Loading from 'src/components/Loading';
import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
import {
ControlPanelSectionConfig,
ControlState,
CustomControlItem,
ExpandedControlItem,
InfoTooltipWithTrigger,
} from '@superset-ui/chart-controls';
import ControlRow from './ControlRow';
import Control from './Control';
import { sectionsToRender } from '../controlUtils';
import { exploreActions } from '../actions/exploreActions';
import { ExploreActions, exploreActions } from '../actions/exploreActions';
import { ExploreState } from '../reducers/getInitialState';
const propTypes = {
actions: PropTypes.object.isRequired,
alert: PropTypes.string,
datasource_type: PropTypes.string.isRequired,
exploreState: PropTypes.object.isRequired,
controls: PropTypes.object.isRequired,
form_data: PropTypes.object.isRequired,
isDatasourceMetaLoading: PropTypes.bool.isRequired,
export type ControlPanelsContainerProps = {
actions: ExploreActions;
datasource_type: DatasourceType;
exploreState: Record<string, any>;
controls: Record<string, ControlState>;
form_data: QueryFormData;
isDatasourceMetaLoading: boolean;
};
export type ExpandedControlPanelSectionConfig = Omit<
ControlPanelSectionConfig,
'controlSetRows'
> & {
controlSetRows: ExpandedControlItem[][];
};
const Styles = styled.div`
@@ -50,9 +67,6 @@ const Styles = styled.div`
overflow: auto;
overflow-x: visible;
overflow-y: auto;
.remove-alert {
cursor: pointer;
}
#controlSections {
min-height: 100%;
overflow: visible;
@@ -80,60 +94,34 @@ const ControlPanelsTabs = styled(Tabs)`
height: 100%;
}
`;
class ControlPanelsContainer extends React.Component {
class ControlPanelsContainer extends React.Component<ControlPanelsContainerProps> {
// trigger updates to the component when async plugins load
static contextType = PluginContext;
constructor(props) {
constructor(props: ControlPanelsContainerProps) {
super(props);
this.removeAlert = this.removeAlert.bind(this);
this.renderControl = this.renderControl.bind(this);
this.renderControlPanelSection = this.renderControlPanelSection.bind(this);
}
componentDidUpdate(prevProps) {
const {
actions: { setControlValue },
} = this.props;
if (this.props.form_data.datasource !== prevProps.form_data.datasource) {
const defaultValues = [
'MetricsControl',
'AdhocFilterControl',
'TextControl',
'SelectControl',
'CheckboxControl',
'AnnotationLayerControl',
];
Object.entries(this.props.controls).forEach(([controlName, control]) => {
const { type, default: defaultValue } = control;
if (defaultValues.includes(type)) {
setControlValue(controlName, defaultValue);
}
});
}
}
sectionsToRender() {
sectionsToRender(): ExpandedControlPanelSectionConfig[] {
return sectionsToRender(
this.props.form_data.viz_type,
this.props.datasource_type,
);
}
sectionsToExpand(sections) {
sectionsToExpand(sections: ControlPanelSectionConfig[]) {
return sections.reduce(
(acc, cur) => (cur.expanded ? [...acc, cur.label] : acc),
[],
(acc, section) =>
section.expanded ? [...acc, String(section.label)] : acc,
[] as string[],
);
}
removeAlert() {
this.props.actions.removeControlPanelAlert();
}
renderControl({ name, config }) {
const { actions, controls, form_data: formData } = this.props;
renderControl({ name, config }: CustomControlItem) {
const { actions, controls } = this.props;
const { visibility } = config;
// If the control item is not an object, we have to look up the control data from
@@ -144,11 +132,9 @@ class ControlPanelsContainer extends React.Component {
...controls[name],
name,
};
const {
validationErrors,
provideFormDataToProps,
...restProps
} = controlData;
const { validationErrors, ...restProps } = controlData as ControlState & {
validationErrors?: any[];
};
// if visibility check says the config is not visible, don't render it
if (visibility && !visibility.call(config, this.props, controlData)) {
@@ -160,30 +146,42 @@ class ControlPanelsContainer extends React.Component {
name={name}
validationErrors={validationErrors}
actions={actions}
formData={provideFormDataToProps ? formData : null}
datasource={formData?.datasource}
{...restProps}
/>
);
}
renderControlPanelSection(section) {
renderControlPanelSection(section: ExpandedControlPanelSectionConfig) {
const { controls } = this.props;
const { label, description } = section;
// Section label can be a ReactNode but in some places we want to
// have a string ID. Using forced type conversion for now,
// should probably add a `id` field to sections in the future.
const sectionId = String(label);
const hasErrors = section.controlSetRows.some(rows =>
rows.some(
s =>
controls[s] &&
controls[s].validationErrors &&
controls[s].validationErrors.length > 0,
),
rows.some(item => {
const controlName =
typeof item === 'string'
? item
: item && 'name' in item
? item.name
: null;
return (
controlName &&
controlName in controls &&
controls[controlName].validationErrors &&
controls[controlName].validationErrors.length > 0
);
}),
);
const PanelHeader = () => (
<span>
<span>{label}</span>{' '}
{description && (
<InfoTooltipWithTrigger label={label} tooltip={description} />
// label is only used in tooltip id (should probably call this prop `id`)
<InfoTooltipWithTrigger label={sectionId} tooltip={description} />
)}
{hasErrors && (
<InfoTooltipWithTrigger
@@ -199,7 +197,7 @@ class ControlPanelsContainer extends React.Component {
<Collapse.Panel
className="control-panel-section"
header={PanelHeader()}
key={section.label}
key={sectionId}
>
{section.controlSetRows.map((controlSets, i) => {
const renderedControls = controlSets
@@ -229,7 +227,6 @@ class ControlPanelsContainer extends React.Component {
return (
<ControlRow
key={`controlsetrow-${i}`}
className="control-row"
controls={renderedControls}
/>
);
@@ -247,8 +244,8 @@ class ControlPanelsContainer extends React.Component {
return <Loading />;
}
const querySectionsToRender = [];
const displaySectionsToRender = [];
const querySectionsToRender: ExpandedControlPanelSectionConfig[] = [];
const displaySectionsToRender: ExpandedControlPanelSectionConfig[] = [];
this.sectionsToRender().forEach(section => {
// if at least one control in the section is not `renderTrigger`
// or asks to be displayed at the Data tab
@@ -258,6 +255,8 @@ class ControlPanelsContainer extends React.Component {
rows.some(
control =>
control &&
typeof control === 'object' &&
'config' in control &&
control.config &&
(!control.config.renderTrigger ||
control.config.tabOverride === 'data'),
@@ -277,14 +276,6 @@ class ControlPanelsContainer extends React.Component {
);
return (
<Styles>
{this.props.alert && (
<Alert
type="warning"
message={this.props.alert}
closable
onClose={this.removeAlert}
/>
)}
<ControlPanelsTabs
id="controlSections"
data-test="control-tabs"
@@ -318,26 +309,19 @@ class ControlPanelsContainer extends React.Component {
}
}
ControlPanelsContainer.propTypes = propTypes;
function mapStateToProps({ explore }) {
return {
alert: explore.controlPanelAlert,
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
controls: explore.controls,
exploreState: explore,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(exploreActions, dispatch),
};
}
export { ControlPanelsContainer };
export default connect(
mapStateToProps,
mapDispatchToProps,
function mapStateToProps({ explore }: ExploreState) {
return {
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
controls: explore.controls,
exploreState: explore,
};
},
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(exploreActions, dispatch),
};
},
)(ControlPanelsContainer);

View File

@@ -16,20 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ExpandedControlItem } from '@superset-ui/chart-controls';
import React from 'react';
import PropTypes from 'prop-types';
const NUM_COLUMNS = 12;
const propTypes = {
controls: PropTypes.arrayOf(PropTypes.object).isRequired,
};
function ControlSetRow(props) {
const colSize = NUM_COLUMNS / props.controls.length;
export default function ControlRow({
controls,
}: {
controls: ExpandedControlItem[];
}) {
const colSize = NUM_COLUMNS / controls.length;
return (
<div className="row space-1">
{props.controls.map((control, i) => (
{controls.map((control, i) => (
<div className={`col-lg-${colSize} col-xs-12`} key={i}>
{control}
</div>
@@ -37,6 +37,3 @@ function ControlSetRow(props) {
</div>
);
}
ControlSetRow.propTypes = propTypes;
export default ControlSetRow;

View File

@@ -222,10 +222,7 @@ function ExploreViewContainer(props) {
}
function onQuery() {
// remove alerts when query
props.actions.removeControlPanelAlert();
props.actions.triggerQuery(true, props.chart.id);
addHistory();
}

View File

@@ -69,12 +69,6 @@ export default class CollectionControl extends React.Component {
this.onAdd = this.onAdd.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.datasource.name !== this.props.datasource.name) {
this.props.onChange([]);
}
}
onChange(i, value) {
Object.assign(this.props.value[i], value);
this.props.onChange(this.props.value);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import rison from 'rison';
import {
SupersetClient,
@@ -25,6 +25,7 @@ import {
t,
TimeRangeEndpoints,
} from '@superset-ui/core';
import { DatasourceMeta } from '@superset-ui/chart-controls';
import {
buildTimeRangeString,
formatTimeRange,
@@ -38,6 +39,8 @@ import { Divider } from 'src/common/components';
import Icon from 'src/components/Icon';
import { Select } from 'src/components/Select';
import { Tooltip } from 'src/common/components/Tooltip';
import { DEFAULT_TIME_RANGE } from 'src/explore/constants';
import { SelectOptionType, FrameType } from './types';
import {
COMMON_RANGE_VALUES_SET,
@@ -165,28 +168,27 @@ const IconWrapper = styled.span`
}
`;
interface DateFilterLabelProps {
interface DateFilterControlProps {
name: string;
onChange: (timeRange: string) => void;
value?: string;
endpoints?: TimeRangeEndpoints;
datasource?: string;
datasource?: DatasourceMeta;
}
export default function DateFilterControl(props: DateFilterLabelProps) {
const { value = 'Last week', endpoints, onChange, datasource } = props;
export default function DateFilterControl(props: DateFilterControlProps) {
const { value = DEFAULT_TIME_RANGE, endpoints, onChange } = props;
const [actualTimeRange, setActualTimeRange] = useState<string>(value);
const [show, setShow] = useState<boolean>(false);
const [frame, setFrame] = useState<FrameType>(guessFrame(value));
const [isMounted, setIsMounted] = useState<boolean>(false);
const guessedFrame = useMemo(() => guessFrame(value), [value]);
const [frame, setFrame] = useState<FrameType>(guessedFrame);
const [timeRangeValue, setTimeRangeValue] = useState(value);
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
const [evalResponse, setEvalResponse] = useState<string>(value);
const [tooltipTitle, setTooltipTitle] = useState<string>(value);
useEffect(() => {
if (!isMounted) setIsMounted(true);
fetchTimeRange(value, endpoints).then(({ value: actualRange, error }) => {
if (error) {
setEvalResponse(error || '');
@@ -205,9 +207,9 @@ export default function DateFilterControl(props: DateFilterLabelProps) {
+--------------+------+----------+--------+----------+-----------+
*/
if (
frame === 'Common' ||
frame === 'Calendar' ||
frame === 'No filter'
guessedFrame === 'Common' ||
guessedFrame === 'Calendar' ||
guessedFrame === 'No filter'
) {
setActualTimeRange(value);
setTooltipTitle(actualRange || '');
@@ -220,14 +222,6 @@ export default function DateFilterControl(props: DateFilterLabelProps) {
});
}, [value]);
useEffect(() => {
if (isMounted) {
onChange('Last week');
setTimeRangeValue('Last week');
setFrame(guessFrame('Last week'));
}
}, [datasource]);
useEffect(() => {
fetchTimeRange(timeRangeValue, endpoints).then(({ value, error }) => {
if (error) {
@@ -247,13 +241,13 @@ export default function DateFilterControl(props: DateFilterLabelProps) {
function onOpen() {
setTimeRangeValue(value);
setFrame(guessFrame(value));
setFrame(guessedFrame);
setShow(true);
}
function onHide() {
setTimeRangeValue(value);
setFrame(guessFrame(value));
setFrame(guessedFrame);
setShow(false);
}
@@ -265,7 +259,7 @@ export default function DateFilterControl(props: DateFilterLabelProps) {
}
};
function onFrame(option: SelectOptionType) {
function onChangeFrame(option: SelectOptionType) {
if (option.value === 'No filter') {
setTimeRangeValue('No filter');
}
@@ -278,7 +272,7 @@ export default function DateFilterControl(props: DateFilterLabelProps) {
<Select
options={FRAME_OPTIONS}
value={FRAME_OPTIONS.filter(({ value }) => value === frame)}
onChange={onFrame}
onChange={onChangeFrame}
className="frame-dropdown"
/>
{frame !== 'No filter' && <Divider />}

View File

@@ -18,7 +18,13 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
import { t, logging, SupersetClient, withTheme } from '@superset-ui/core';
import {
t,
logging,
SupersetClient,
withTheme,
ensureIsArray,
} from '@superset-ui/core';
import ControlHeader from 'src/explore/components/ControlHeader';
import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType';
@@ -39,6 +45,11 @@ import AdhocFilterOption from './AdhocFilterOption';
import AdhocFilter, { CLAUSES, EXPRESSION_TYPES } from './AdhocFilter';
import adhocFilterType from './adhocFilterType';
const selectedMetricType = PropTypes.oneOfType([
PropTypes.string,
adhocMetricType,
]);
const propTypes = {
name: PropTypes.string,
onChange: PropTypes.func,
@@ -46,12 +57,10 @@ const propTypes = {
datasource: PropTypes.object,
columns: PropTypes.arrayOf(columnType),
savedMetrics: PropTypes.arrayOf(savedMetricType),
formData: PropTypes.shape({
metric: PropTypes.oneOfType([PropTypes.string, adhocMetricType]),
metrics: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.string, adhocMetricType]),
),
}),
selectedMetrics: PropTypes.oneOfType([
selectedMetricType,
PropTypes.arrayOf(selectedMetricType),
]),
isLoading: PropTypes.bool,
};
@@ -60,7 +69,7 @@ const defaultProps = {
onChange: () => {},
columns: [],
savedMetrics: [],
formData: {},
selectedMetrics: [],
};
function isDictionaryForAdhocFilter(value) {
@@ -141,10 +150,7 @@ class AdhocFilterControl extends React.Component {
}
UNSAFE_componentWillReceiveProps(nextProps) {
if (
this.props.columns !== nextProps.columns ||
this.props.formData !== nextProps.formData
) {
if (this.props.columns !== nextProps.columns) {
this.setState({ options: this.optionsForSelect(nextProps) });
}
if (this.props.value !== nextProps.value) {
@@ -270,7 +276,7 @@ class AdhocFilterControl extends React.Component {
optionsForSelect(props) {
const options = [
...props.columns,
...[...(props.formData?.metrics || []), props.formData?.metric].map(
...ensureIsArray(props.selectedMetrics).map(
metric =>
metric &&
(typeof metric === 'string'

View File

@@ -123,9 +123,6 @@ export default class AdhocMetricEditPopover extends React.PureComponent {
adhocMetricLabel: this.state.adhocMetric?.getDefaultLabel(),
});
}
if (prevProps.datasource !== this.props.datasource) {
this.props.onChange(null);
}
}
componentWillUnmount() {

View File

@@ -60,7 +60,6 @@ class AdhocMetricOption extends React.PureComponent {
onMoveLabel,
onDropLabel,
index,
datasource,
} = this.props;
return (
@@ -70,7 +69,6 @@ class AdhocMetricOption extends React.PureComponent {
columns={columns}
savedMetricsOptions={savedMetricsOptions}
savedMetric={savedMetric}
datasource={datasource}
datasourceType={datasourceType}
>
<OptionControlLabel

View File

@@ -33,7 +33,6 @@ export type AdhocMetricPopoverTriggerProps = {
savedMetricsOptions: savedMetricType[];
savedMetric: savedMetricType;
datasourceType: string;
datasource: string;
children: ReactNode;
createNew?: boolean;
};
@@ -160,7 +159,6 @@ class AdhocMetricPopoverTrigger extends React.PureComponent<
columns={this.props.columns}
savedMetricsOptions={this.props.savedMetricsOptions}
savedMetric={this.props.savedMetric}
datasource={this.props.datasource}
datasourceType={this.props.datasourceType}
onResize={this.onPopoverResize}
onClose={this.closePopover}

View File

@@ -38,7 +38,6 @@ const propTypes = {
savedMetricsOptions: PropTypes.arrayOf(savedMetricType),
multi: PropTypes.bool,
datasourceType: PropTypes.string,
datasource: PropTypes.string,
};
export default function MetricDefinitionValue({
@@ -52,16 +51,15 @@ export default function MetricDefinitionValue({
onMoveLabel,
onDropLabel,
index,
datasource,
}) {
const getSavedMetricByName = metricName =>
savedMetrics.find(metric => metric.metric_name === metricName);
let savedMetric;
if (option.metric_name) {
savedMetric = option;
} else if (typeof option === 'string') {
if (typeof option === 'string') {
savedMetric = getSavedMetricByName(option);
} else if (option.metric_name) {
savedMetric = option;
}
if (option instanceof AdhocMetric || savedMetric) {
@@ -79,7 +77,6 @@ export default function MetricDefinitionValue({
onDropLabel,
index,
savedMetric: savedMetric ?? {},
datasource,
};
return <AdhocMetricOption {...metricOptionProps} />;

View File

@@ -179,10 +179,10 @@ class MetricsControl extends React.PureComponent {
) {
this.setState({ options: this.optionsForSelect(nextProps) });
// Remove metrics if selected value no longer a column
const containsAllMetrics = columnsContainAllMetrics(value, nextProps);
if (!containsAllMetrics) {
// Remove all metrics if selected value no longer a valid column
// in the dataset. Must use `nextProps` here because Redux reducers may
// have already updated the value for this control.
if (!columnsContainAllMetrics(nextProps.value, nextProps)) {
this.props.onChange([]);
}
}

View File

@@ -17,71 +17,55 @@
* under the License.
*/
import React from 'react';
import { FormGroup, FormControl } from 'react-bootstrap';
import { FormGroup, FormControl, FormControlProps } from 'react-bootstrap';
import { legacyValidateNumber, legacyValidateInteger } from '@superset-ui/core';
import debounce from 'lodash/debounce';
import ControlHeader from '../ControlHeader';
import { FAST_DEBOUNCE } from 'src/constants';
import ControlHeader from 'src/explore/components/ControlHeader';
interface TextControlProps {
type InputValueType = string | number;
export interface TextControlProps<T extends InputValueType = InputValueType> {
disabled?: boolean;
isFloat?: boolean;
isInt?: boolean;
onChange?: (value: any, errors: any) => {};
onChange?: (value: T, errors: any) => {};
onFocus?: () => {};
placeholder?: string;
value?: string | number;
value?: T | null;
controlId?: string;
renderTrigger?: boolean;
datasource?: string;
}
interface TextControlState {
export interface TextControlState {
controlId: string;
currentDatasource?: string;
value?: string | number;
value: string;
}
const generateControlId = (controlId?: string) =>
`formInlineName_${controlId ?? (Math.random() * 1000000).toFixed()}`;
export default class TextControl extends React.Component<
TextControlProps,
TextControlState
> {
debouncedOnChange = debounce((inputValue: string) => {
this.onChange(inputValue);
}, 500);
const safeStringify = (value?: InputValueType | null) =>
value == null ? '' : String(value);
static getDerivedStateFromProps(
props: TextControlProps,
state: TextControlState,
) {
// reset value when datasource changes
// props.datasource and props.value don't update in the same re-render,
// so we need to synchronize them to update the state with correct values
if (
props.value !== state.value &&
props.datasource !== state.currentDatasource
) {
return { value: props.value, currentDatasource: props.datasource };
}
return null;
}
export default class TextControl<
T extends InputValueType = InputValueType
> extends React.Component<TextControlProps<T>, TextControlState> {
initialValue?: TextControlProps['value'];
constructor(props: TextControlProps) {
constructor(props: TextControlProps<T>) {
super(props);
// if there's no control id provided, generate a random
// number to prevent rendering elements with same ids
this.initialValue = props.value;
this.state = {
// if there's no control id provided, generate a random
// number to prevent rendering elements with same ids
controlId: generateControlId(props.controlId),
value: props.value,
currentDatasource: props.datasource,
value: safeStringify(this.initialValue),
};
}
onChange = (inputValue: string) => {
let parsedValue: string | number = inputValue;
let parsedValue: InputValueType = inputValue;
// Validation & casting
const errors = [];
if (inputValue !== '' && this.props.isFloat) {
@@ -102,26 +86,26 @@ export default class TextControl extends React.Component<
parsedValue = parseInt(inputValue, 10);
}
}
this.props.onChange?.(parsedValue, errors);
this.props.onChange?.(parsedValue as T, errors);
};
onChangeWrapper = (event: any) => {
const { value } = event.target;
this.setState({ value });
debouncedOnChange = debounce((inputValue: string) => {
this.onChange(inputValue);
}, FAST_DEBOUNCE);
// use debounce when change takes effect immediately after user starts typing
const onChange = this.props.renderTrigger
? this.debouncedOnChange
: this.onChange;
onChange(value);
onChangeWrapper: FormControlProps['onChange'] = event => {
const { value } = event.target as HTMLInputElement;
this.setState({ value }, () => {
this.debouncedOnChange(value);
});
};
render = () => {
const { value: rawValue } = this.state;
const value =
typeof rawValue !== 'undefined' && rawValue !== null
? rawValue.toString()
: '';
let { value } = this.state;
if (this.initialValue !== this.props.value) {
this.initialValue = this.props.value;
value = safeStringify(this.props.value);
}
return (
<div>
<ControlHeader {...this.props} />