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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 385 additions and 326 deletions

View File

@ -104,6 +104,7 @@ module.exports = {
'no-mixed-operators': 0,
'no-multi-assign': 0,
'no-multi-spaces': 0,
'no-nested-ternary': 0,
'no-prototype-builtins': 0,
'no-restricted-properties': 0,
'no-restricted-imports': [
@ -226,6 +227,7 @@ module.exports = {
'no-mixed-operators': 0,
'no-multi-assign': 0,
'no-multi-spaces': 0,
'no-nested-ternary': 0,
'no-prototype-builtins': 0,
'no-restricted-properties': 0,
'no-restricted-imports': [

View File

@ -55,44 +55,3 @@ describe('Advanced analytics', () => {
.contains('1 year');
});
});
describe('Annotations', () => {
beforeEach(() => {
cy.login();
cy.intercept('GET', '/superset/explore_json/**').as('getJson');
cy.intercept('POST', '/superset/explore_json/**').as('postJson');
});
it('Create formula annotation y-axis goal line', () => {
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@postJson' });
cy.get('[data-test=annotation_layers]').within(() => {
cy.get('button').click();
});
cy.get('[data-test="popover-content"]').within(() => {
cy.get('[data-test=annotation-layer-name-header]')
.siblings()
.first()
.within(() => {
cy.get('input').type('Goal line');
});
cy.get('[data-test=annotation-layer-value-header]')
.siblings()
.first()
.within(() => {
cy.get('input').type('y=1400000');
});
cy.get('button').contains('OK').click({ force: true });
});
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({
waitAlias: '@postJson',
chartSelector: 'svg',
});
cy.get('.nv-legend-text').should('have.length', 2);
});
});

View File

@ -0,0 +1,59 @@
/**
* 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.
*/
describe('Annotations', () => {
beforeEach(() => {
cy.login();
cy.intercept('GET', '/superset/explore_json/**').as('getJson');
cy.intercept('POST', '/superset/explore_json/**').as('postJson');
});
it('Create formula annotation y-axis goal line', () => {
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@postJson' });
const layerLabel = 'Goal line';
cy.get('[data-test=annotation_layers] button').click();
cy.get('[data-test="popover-content"]').within(() => {
cy.get('[data-test=annotation-layer-name-header]')
.siblings()
.first()
.within(() => {
cy.get('input').type(layerLabel);
});
cy.get('[data-test=annotation-layer-value-header]')
.siblings()
.first()
.within(() => {
cy.get('input').type('y=1400000');
});
cy.get('button').contains('OK').click();
});
cy.get('button[data-test="run-query-button"]').click();
cy.get('[data-test=annotation_layers]').contains(layerLabel);
cy.verifySliceSuccess({
waitAlias: '@postJson',
chartSelector: 'svg',
});
cy.get('.nv-legend-text').should('have.length', 2);
});
});

View File

@ -28,7 +28,7 @@ describe('Visualization > Pivot Table', () => {
adhoc_filters: [],
groupby: ['name'],
columns: ['state'],
row_limit: 50000,
row_limit: 5000,
pandas_aggfunc: 'sum',
pivot_margins: true,
number_format: '.3s',

View File

@ -18,10 +18,17 @@
*/
import React from 'react';
import { shallow } from 'enzyme';
import { getChartControlPanelRegistry, t } from '@superset-ui/core';
import {
DatasourceType,
getChartControlPanelRegistry,
t,
} from '@superset-ui/core';
import { defaultControls } from 'src/explore/store';
import { getFormDataFromControls } from 'src/explore/controlUtils';
import { ControlPanelsContainer } from 'src/explore/components/ControlPanelsContainer';
import {
ControlPanelsContainer,
ControlPanelsContainerProps,
} from 'src/explore/components/ControlPanelsContainer';
import Collapse from 'src/common/components/Collapse';
describe('ControlPanelsContainer', () => {
@ -78,15 +85,15 @@ describe('ControlPanelsContainer', () => {
});
function getDefaultProps() {
const controls = defaultControls as ControlPanelsContainerProps['controls'];
return {
datasource_type: 'table',
datasource_type: DatasourceType.Table,
actions: {},
controls: defaultControls,
// Note: default viz_type is table
form_data: getFormDataFromControls(defaultControls),
controls,
form_data: getFormDataFromControls(controls),
isDatasourceMetaLoading: false,
exploreState: {},
};
} as ControlPanelsContainerProps;
}
it('renders ControlPanelSections', () => {

View File

@ -17,7 +17,6 @@
* under the License.
*/
export const DATETIME_WITH_TIME_ZONE = 'YYYY-MM-DD HH:mm:ssZ';
export const TIME_WITH_MS = 'HH:mm:ss.SSS';
export const BOOL_TRUE_DISPLAY = 'True';

View File

@ -99,7 +99,7 @@ export const SET_FIELD_VALUE = 'SET_FIELD_VALUE';
export function setControlValue(
controlName: string,
value: any,
validationErrors: any[],
validationErrors?: any[],
) {
return { type: SET_FIELD_VALUE, controlName, value, validationErrors };
}
@ -109,11 +109,6 @@ export function setExploreControls(formData: QueryFormData) {
return { type: SET_EXPLORE_CONTROLS, formData };
}
export const REMOVE_CONTROL_PANEL_ALERT = 'REMOVE_CONTROL_PANEL_ALERT';
export function removeControlPanelAlert() {
return { type: REMOVE_CONTROL_PANEL_ALERT };
}
export const UPDATE_CHART_TITLE = 'UPDATE_CHART_TITLE';
export function updateChartTitle(sliceName: string) {
return { type: UPDATE_CHART_TITLE, sliceName };
@ -154,7 +149,6 @@ export const exploreActions = {
saveFaveStar,
setControlValue,
setExploreControls,
removeControlPanelAlert,
updateChartTitle,
createNewSlice,
sliceUpdated,

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} />

View File

@ -55,12 +55,7 @@ export const HAVING_OPERATORS = [
OPERATORS['>='],
OPERATORS['<='],
];
export const MULTI_OPERATORS = new Set([
OPERATORS.in,
OPERATORS['not in'],
OPERATORS.IN,
OPERATORS['NOT IN'],
]);
export const MULTI_OPERATORS = new Set([OPERATORS.IN, OPERATORS['NOT IN']]);
// CUSTOM_OPERATORS will show operator in simple mode,
// but will generate customized sqlExpression
export const CUSTOM_OPERATORS = new Set([OPERATORS['LATEST PARTITION']]);
@ -103,3 +98,6 @@ export const TIME_FILTER_MAP = {
druid_time_origin: '__time_origin',
granularity: '__granularity',
};
// TODO: make this configurable per Superset installation
export const DEFAULT_TIME_RANGE = 'Last week';

View File

@ -0,0 +1,34 @@
/**
* 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 { QueryFormData } from '@superset-ui/core';
import { ControlStateMapping } from '@superset-ui/chart-controls';
export function getFormDataFromControls(
controlsState: ControlStateMapping,
): QueryFormData {
const formData: QueryFormData = {
viz_type: 'table',
datasource: '',
};
Object.keys(controlsState).forEach(controlName => {
const control = controlsState[controlName];
formData[controlName] = control.value;
});
return formData;
}

View File

@ -19,16 +19,9 @@
import memoizeOne from 'memoize-one';
import { getChartControlPanelRegistry } from '@superset-ui/core';
import { expandControlConfig } from '@superset-ui/chart-controls';
import * as SECTIONS from './controlPanels/sections';
import * as SECTIONS from '../controlPanels/sections';
export function getFormDataFromControls(controlsState) {
const formData = {};
Object.keys(controlsState).forEach(controlName => {
const control = controlsState[controlName];
formData[controlName] = control.value;
});
return formData;
}
export * from './getFormDataFromControls';
export function validateControl(control, processedState) {
const { validators } = control;

View File

@ -355,8 +355,9 @@ export const controls = {
"using the engine's local timezone. Note one can explicitly set the timezone " +
'per the ISO 8601 format if specifying either the start and/or end time.',
),
mapStateToProps: state => ({
endpoints: state.form_data ? state.form_data.time_range_endpoints : null,
mapStateToProps: ({ form_data: formData }) => ({
// eslint-disable-next-line camelcase
endpoints: formData?.time_range_endpoints,
}),
},
@ -474,7 +475,6 @@ export const controls = {
savedMetrics: state.datasource ? state.datasource.metrics : [],
datasource: state.datasource,
}),
provideFormDataToProps: true,
},
color_scheme: {

View File

@ -18,13 +18,14 @@
*/
/* eslint camelcase: 0 */
import { DYNAMIC_PLUGIN_CONTROLS_READY } from 'src/chart/chartAction';
import { getControlsState } from '../store';
import { DEFAULT_TIME_RANGE } from 'src/explore/constants';
import { getControlsState } from 'src/explore/store';
import {
getControlConfig,
getFormDataFromControls,
getControlStateFromControlConfig,
} from '../controlUtils';
import * as actions from '../actions/exploreActions';
} from 'src/explore/controlUtils';
import * as actions from 'src/explore/actions/exploreActions';
export default function exploreReducer(state = {}, action) {
const actionHandlers = {
@ -61,8 +62,38 @@ export default function exploreReducer(state = {}, action) {
delete newFormData.time_grain_sqla;
}
}
const controls = { ...state.controls };
if (
action.datasource.id !== state.datasource.id ||
action.datasource.type !== state.datasource.type
) {
// reset time range filter to default
newFormData.time_range = DEFAULT_TIME_RANGE;
// reset control values for column/metric related controls
Object.entries(controls).forEach(([controlName, controlState]) => {
if (
// for direct column select controls
controlState.valueKey === 'column_name' ||
// for all other controls
'columns' in controlState
) {
// if a control use datasource columns, reset its value to `undefined`,
// then `getControlsState` will pick up the default.
// TODO: filter out only invalid columns and keep others
controls[controlName] = {
...controlState,
value: undefined,
};
newFormData[controlName] = undefined;
}
});
}
const newState = {
...state,
controls,
datasource: action.datasource,
datasource_id: action.datasource.id,
datasource_type: action.datasource.type,
@ -85,12 +116,6 @@ export default function exploreReducer(state = {}, action) {
datasources: action.datasources,
};
},
[actions.REMOVE_CONTROL_PANEL_ALERT]() {
return {
...state,
controlPanelAlert: null,
};
},
[actions.SET_FIELD_VALUE]() {
const new_form_data = state.form_data;
const { controlName, value, validationErrors } = action;

View File

@ -17,41 +17,72 @@
* under the License.
*/
import shortid from 'shortid';
import {
Datasource,
DatasourceType,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { Slice } from 'src/types/Chart';
import { CommonBootstrapData } from 'src/types/bootstrapTypes';
import getToastsFromPyFlashMessages from '../../messageToasts/utils/getToastsFromPyFlashMessages';
import { getChartKey } from '../exploreUtils';
import { getControlsState } from '../store';
import getToastsFromPyFlashMessages from 'src/messageToasts/utils/getToastsFromPyFlashMessages';
import { getChartKey } from 'src/explore/exploreUtils';
import { getControlsState } from 'src/explore/store';
import {
getFormDataFromControls,
applyMapStateToPropsToControl,
} from '../controlUtils';
} from 'src/explore/controlUtils';
import { ControlStateMapping } from '@superset-ui/chart-controls';
export default function getInitialState(bootstrapData) {
const { form_data: rawFormData } = bootstrapData;
export interface ExlorePageBootstrapData extends JsonObject {
can_add: boolean;
can_download: boolean;
can_overwrite: boolean;
datasource: Datasource;
form_data: QueryFormData;
datasource_id: number;
datasource_type: DatasourceType;
slice: Slice | null;
standalone: boolean;
user_id: number;
forced_height: string | null;
common: CommonBootstrapData;
}
export default function getInitialState(
bootstrapData: ExlorePageBootstrapData,
) {
const { form_data: initialFormData } = bootstrapData;
const { slice } = bootstrapData;
const sliceName = slice ? slice.slice_name : null;
const bootstrappedState = {
const exploreState = {
// note this will add `form_data` to state,
// which will be manipulatable by future reducers.
...bootstrapData,
sliceName,
common: {
flash_messages: bootstrapData.common.flash_messages,
conf: bootstrapData.common.conf,
},
rawFormData,
filterColumnOpts: [],
isDatasourceMetaLoading: false,
isStarred: false,
// Initial control state will skip `control.mapStateToProps`
// because `bootstrapData.controls` is undefined.
controls: getControlsState(
bootstrapData,
initialFormData,
) as ControlStateMapping,
};
const controls = getControlsState(bootstrappedState, rawFormData);
bootstrappedState.controls = controls;
// apply initial mapStateToProps for all controls, must execute AFTER
// bootstrappedState has initialized `controls`. Order of execution is not
// bootstrapState has initialized `controls`. Order of execution is not
// guaranteed, so controls shouldn't rely on the each other's mapped state.
Object.entries(controls).forEach(([key, controlState]) => {
controls[key] = applyMapStateToPropsToControl(
Object.entries(exploreState.controls).forEach(([key, controlState]) => {
exploreState.controls[key] = applyMapStateToPropsToControl(
controlState,
bootstrappedState,
exploreState,
);
});
@ -59,7 +90,7 @@ export default function getInitialState(bootstrapData) {
? getFormDataFromControls(getControlsState(bootstrapData, slice.form_data))
: null;
const chartKey = getChartKey(bootstrappedState);
const chartKey: number = getChartKey(bootstrapData);
return {
charts: {
@ -69,7 +100,7 @@ export default function getInitialState(bootstrapData) {
chartStatus: null,
chartUpdateEndTime: null,
chartUpdateStartTime: 0,
latestQueryFormData: getFormDataFromControls(controls),
latestQueryFormData: getFormDataFromControls(exploreState.controls),
sliceFormData,
queryController: null,
queriesResponse: null,
@ -81,10 +112,12 @@ export default function getInitialState(bootstrapData) {
dashboards: [],
saveModalAlert: null,
},
explore: bootstrappedState,
explore: exploreState,
impressionId: shortid.generate(),
messageToasts: getToastsFromPyFlashMessages(
(bootstrapData.common || {}).flash_messages || [],
),
};
}
export type ExploreState = ReturnType<typeof getInitialState>;

View File

@ -21,6 +21,7 @@
* The Chart model as returned from the API
*/
import { QueryFormData } from '@superset-ui/core';
import Owner from './Owner';
export interface Chart {
@ -45,7 +46,7 @@ export type Slice = {
slice_name: string;
description: string | null;
cache_timeout: number | null;
url?: string;
form_data?: QueryFormData;
};
export default Chart;

View File

@ -1,3 +1,5 @@
import { JsonObject, Locale } from '@superset-ui/core';
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@ -42,3 +44,10 @@ export type Dashboard = {
creator?: string;
creator_url?: string;
};
export interface CommonBootstrapData {
flash_messages: string[][];
conf: JsonObject;
locale: Locale;
feature_flags: Record<string, boolean>;
}