fix(explore): Prevent shared controls from checking feature flags outside React render (#21315)

This commit is contained in:
Cody Leff 2022-09-14 14:41:47 -04:00 committed by GitHub
parent 59ca7861c0
commit 2285ebe72e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 285 additions and 435 deletions

View File

@ -31,11 +31,7 @@ export * from './components/ColumnTypeLabel/ColumnTypeLabel';
export * from './components/MetricOption';
// React control components
export {
sharedControls,
dndEntity,
dndColumnsControl,
} from './shared-controls';
export { default as sharedControls, withDndFallback } from './shared-controls';
export { default as sharedControlComponents } from './shared-controls/components';
export { legacySortBy } from './shared-controls/legacySortBy';
export * from './shared-controls/emitFilterControl';

View File

@ -17,6 +17,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useMemo } from 'react';
import {
FeatureFlag,
isFeatureEnabled,
@ -25,43 +26,83 @@ import {
t,
validateNonEmpty,
} from '@superset-ui/core';
import { ExtraControlProps, SharedControlConfig, Dataset } from '../types';
import {
ExtraControlProps,
SharedControlConfig,
Dataset,
Metric,
} from '../types';
import { DATASET_TIME_COLUMN_OPTION, TIME_FILTER_LABELS } from '../constants';
import { QUERY_TIME_COLUMN_OPTION, defineSavedMetrics } from '..';
import {
QUERY_TIME_COLUMN_OPTION,
defineSavedMetrics,
ColumnOption,
ColumnMeta,
FilterOption,
} from '..';
import { xAxisControlConfig } from './constants';
export const dndGroupByControl: SharedControlConfig<'DndColumnSelect'> = {
type Control = {
savedMetrics?: Metric[] | null;
default?: unknown;
};
/*
* Note: Previous to the commit that introduced this comment, the shared controls module
* would check feature flags at module execution time and expose a different control
* configuration (component + props) depending on the status of drag-and-drop feature
* flags. This commit combines those configs, merging the required props for both the
* drag-and-drop and non-drag-and-drop components, and renders a wrapper component that
* checks feature flags at component render time to avoid race conditions between when
* feature flags are set and when they're checked.
*/
export const dndGroupByControl: SharedControlConfig<
'DndColumnSelect' | 'SelectControl',
ColumnMeta
> = {
type: 'DndColumnSelect',
label: t('Dimensions'),
multi: true,
freeForm: true,
clearable: true,
default: [],
includeTime: false,
description: t(
'One or many columns to group by. High cardinality groupings should include a series limit ' +
'to limit the number of fetched and rendered series.',
'One or many columns to group by. High cardinality groupings should include a sort by metric ' +
'and series limit to limit the number of fetched and rendered series.',
),
mapStateToProps(state, { includeTime }) {
optionRenderer: (c: ColumnMeta) => <ColumnOption showType column={c} />,
valueRenderer: (c: ColumnMeta) => <ColumnOption column={c} />,
valueKey: 'column_name',
allowAll: true,
filterOption: ({ data: opt }: FilterOption<ColumnMeta>, text: string) =>
(opt.column_name &&
opt.column_name.toLowerCase().includes(text.toLowerCase())) ||
(opt.verbose_name &&
opt.verbose_name.toLowerCase().includes(text.toLowerCase())) ||
false,
promptTextCreator: (label: unknown) => label,
mapStateToProps(state, controlState) {
const newState: ExtraControlProps = {};
const { datasource } = state;
if (datasource?.columns[0]?.hasOwnProperty('groupby')) {
const options = (datasource as Dataset).columns.filter(c => c.groupby);
if (includeTime) {
if (controlState?.includeTime) {
options.unshift(DATASET_TIME_COLUMN_OPTION);
}
newState.options = Object.fromEntries(
options.map(option => [option.column_name, option]),
);
newState.options = options;
newState.savedMetrics = (datasource as Dataset).metrics || [];
} else {
const options = datasource?.columns;
if (includeTime) {
(options as QueryColumn[])?.unshift(QUERY_TIME_COLUMN_OPTION);
const options = (datasource?.columns as QueryColumn[]) || [];
if (controlState?.includeTime) {
options.unshift(QUERY_TIME_COLUMN_OPTION);
}
newState.options = Object.fromEntries(
(options as QueryColumn[])?.map(option => [option.name, option]),
);
newState.options = datasource?.columns;
newState.options = options;
}
return newState;
},
commaChoosesOption: false,
};
export const dndColumnsControl: typeof dndGroupByControl = {
@ -70,7 +111,7 @@ export const dndColumnsControl: typeof dndGroupByControl = {
description: t('One or many columns to pivot as columns'),
};
export const dndSeries: typeof dndGroupByControl = {
export const dndSeriesControl: typeof dndGroupByControl = {
...dndGroupByControl,
label: t('Dimension'),
multi: false,
@ -82,7 +123,7 @@ export const dndSeries: typeof dndGroupByControl = {
),
};
export const dndEntity: typeof dndGroupByControl = {
export const dndEntityControl: typeof dndGroupByControl = {
...dndGroupByControl,
label: t('Entity'),
default: null,
@ -91,7 +132,9 @@ export const dndEntity: typeof dndGroupByControl = {
description: t('This defines the element to be plotted on the chart'),
};
export const dnd_adhoc_filters: SharedControlConfig<'DndFilterSelect'> = {
export const dndAdhocFilterControl: SharedControlConfig<
'DndFilterSelect' | 'AdhocFilterControl'
> = {
type: 'DndFilterSelect',
label: t('Filters'),
default: [],
@ -109,7 +152,9 @@ export const dnd_adhoc_filters: SharedControlConfig<'DndFilterSelect'> = {
provideFormDataToProps: true,
};
export const dnd_adhoc_metrics: SharedControlConfig<'DndMetricSelect'> = {
export const dndAdhocMetricsControl: SharedControlConfig<
'DndMetricSelect' | 'MetricsControl'
> = {
type: 'DndMetricSelect',
multi: true,
label: t('Metrics'),
@ -123,20 +168,23 @@ export const dnd_adhoc_metrics: SharedControlConfig<'DndMetricSelect'> = {
description: t('One or many metrics to display'),
};
export const dnd_adhoc_metric: SharedControlConfig<'DndMetricSelect'> = {
...dnd_adhoc_metrics,
export const dndAdhocMetricControl: typeof dndAdhocMetricsControl = {
...dndAdhocMetricsControl,
multi: false,
label: t('Metric'),
description: t('Metric'),
};
export const dnd_adhoc_metric_2: SharedControlConfig<'DndMetricSelect'> = {
...dnd_adhoc_metric,
export const dndAdhocMetricControl2: typeof dndAdhocMetricControl = {
...dndAdhocMetricControl,
label: t('Right Axis Metric'),
clearable: true,
description: t('Choose a metric for right axis'),
};
export const dnd_sort_by: SharedControlConfig<'DndMetricSelect'> = {
export const dndSortByControl: SharedControlConfig<
'DndMetricSelect' | 'MetricsControl'
> = {
type: 'DndMetricSelect',
label: t('Sort by'),
default: null,
@ -152,33 +200,37 @@ export const dnd_sort_by: SharedControlConfig<'DndMetricSelect'> = {
}),
};
export const dnd_size: SharedControlConfig<'DndMetricSelect'> = {
...dnd_adhoc_metric,
export const dndSizeControl: typeof dndAdhocMetricControl = {
...dndAdhocMetricControl,
label: t('Bubble Size'),
description: t('Metric used to calculate bubble size'),
default: null,
};
export const dnd_x: SharedControlConfig<'DndMetricSelect'> = {
...dnd_adhoc_metric,
export const dndXControl: typeof dndAdhocMetricControl = {
...dndAdhocMetricControl,
label: t('X Axis'),
description: t('Metric assigned to the [X] axis'),
default: null,
};
export const dnd_y: SharedControlConfig<'DndMetricSelect'> = {
...dnd_adhoc_metric,
export const dndYControl: typeof dndAdhocMetricControl = {
...dndAdhocMetricControl,
label: t('Y Axis'),
description: t('Metric assigned to the [Y] axis'),
default: null,
};
export const dnd_secondary_metric: SharedControlConfig<'DndMetricSelect'> = {
...dnd_adhoc_metric,
export const dndSecondaryMetricControl: typeof dndAdhocMetricControl = {
...dndAdhocMetricControl,
label: t('Color Metric'),
default: null,
validators: [],
description: t('A metric to use for color'),
};
export const dnd_granularity_sqla: typeof dndGroupByControl = {
...dndSeries,
export const dndGranularitySqlaControl: typeof dndSeriesControl = {
...dndSeriesControl,
label: TIME_FILTER_LABELS.granularity_sqla,
description: t(
'The time column for the visualization. Note that you ' +
@ -187,21 +239,20 @@ export const dnd_granularity_sqla: typeof dndGroupByControl = {
'filter below is applied against this column or ' +
'expression',
),
default: (c: Control) => c.default,
clearable: false,
canDelete: false,
ghostButtonText: t(
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
? 'Drop a temporal column here or click'
: 'Drop temporal column here',
),
ghostButtonText: t('Drop temporal column here'),
clickEnabledGhostButtonText: t('Drop a temporal column here or click'),
optionRenderer: (c: ColumnMeta) => <ColumnOption showType column={c} />,
valueRenderer: (c: ColumnMeta) => <ColumnOption column={c} />,
valueKey: 'column_name',
mapStateToProps: ({ datasource }) => {
if (datasource?.columns[0]?.hasOwnProperty('column_name')) {
const temporalColumns =
(datasource as Dataset)?.columns?.filter(c => c.is_dttm) ?? [];
const options = Object.fromEntries(
temporalColumns.map(option => [option.column_name, option]),
);
return {
options,
options: temporalColumns,
default:
(datasource as Dataset)?.main_dttm_col ||
temporalColumns[0]?.column_name ||
@ -209,22 +260,36 @@ export const dnd_granularity_sqla: typeof dndGroupByControl = {
isTemporal: true,
};
}
const sortedQueryColumns = (datasource as QueryResponse)?.columns?.sort(
query => (query?.is_dttm ? -1 : 1),
);
const options = Object.fromEntries(
sortedQueryColumns.map(option => [option.name, option]),
);
return {
options,
options: sortedQueryColumns,
default: sortedQueryColumns[0]?.name || null,
isTemporal: true,
};
},
};
export const dnd_x_axis: SharedControlConfig<'DndColumnSelect'> = {
export const dndXAxisControl: typeof dndGroupByControl = {
...dndGroupByControl,
...xAxisControlConfig,
};
export function withDndFallback(
DndComponent: React.ComponentType<any>,
FallbackComponent: React.ComponentType<any>,
) {
return function DndControl(props: any) {
const enableExploreDnd = useMemo(
() => isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP),
[],
);
return enableExploreDnd ? (
<DndComponent {...props} />
) : (
<FallbackComponent {...props} />
);
};
}

View File

@ -33,7 +33,6 @@
* here's a list of the keys that are common to all controls, and as a result define the
* control interface.
*/
import React from 'react';
import { isEmpty } from 'lodash';
import {
FeatureFlag,
@ -43,10 +42,7 @@ import {
isFeatureEnabled,
SequentialScheme,
legacyValidateInteger,
validateNonEmpty,
ComparisionType,
QueryResponse,
QueryColumn,
isAdhocColumn,
isPhysicalColumn,
} from '@superset-ui/core';
@ -59,38 +55,29 @@ import {
D3_TIME_FORMAT_DOCS,
DEFAULT_TIME_FORMAT,
DEFAULT_NUMBER_FORMAT,
defineSavedMetrics,
} from '../utils';
import { TIME_FILTER_LABELS, DATASET_TIME_COLUMN_OPTION } from '../constants';
import {
Metric,
SharedControlConfig,
ColumnMeta,
ExtraControlProps,
SelectControlConfig,
Dataset,
} from '../types';
import { ColumnOption } from '../components/ColumnOption';
import { TIME_FILTER_LABELS } from '../constants';
import { SharedControlConfig, Dataset } from '../types';
import {
dnd_adhoc_filters,
dnd_adhoc_metric,
dnd_adhoc_metrics,
dnd_granularity_sqla,
dnd_sort_by,
dnd_secondary_metric,
dnd_size,
dnd_x,
dnd_y,
dndAdhocFilterControl,
dndAdhocMetricControl,
dndAdhocMetricsControl,
dndGranularitySqlaControl,
dndSortByControl,
dndSecondaryMetricControl,
dndSizeControl,
dndXControl,
dndYControl,
dndColumnsControl,
dndEntity,
dndEntityControl,
dndGroupByControl,
dndSeries,
dnd_adhoc_metric_2,
dnd_x_axis,
dndSeriesControl,
dndAdhocMetricControl2,
dndXAxisControl,
} from './dndControls';
import { QUERY_TIME_COLUMN_OPTION } from '..';
import { xAxisControlConfig } from './constants';
export { withDndFallback } from './dndControls';
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
const sequentialSchemeRegistry = getSequentialSchemeRegistry();
@ -105,77 +92,11 @@ const { user } = JSON.parse(
appContainer?.getAttribute('data-bootstrap') || '{}',
);
type Control = {
savedMetrics?: Metric[] | null;
default?: unknown;
};
type SelectDefaultOption = {
label: string;
value: string;
};
const groupByControl: SharedControlConfig<'SelectControl', ColumnMeta> = {
type: 'SelectControl',
label: t('Dimensions'),
multi: true,
freeForm: true,
clearable: true,
default: [],
includeTime: false,
description: t(
'One or many columns to group by. High cardinality groupings should include a sort by metric ' +
'and series limit to limit the number of fetched and rendered series.',
),
optionRenderer: c => <ColumnOption showType column={c} />,
valueRenderer: c => <ColumnOption column={c} />,
valueKey: 'column_name',
allowAll: true,
filterOption: ({ data: opt }, text: string) =>
(opt.column_name &&
opt.column_name.toLowerCase().includes(text.toLowerCase())) ||
(opt.verbose_name &&
opt.verbose_name.toLowerCase().includes(text.toLowerCase())) ||
false,
promptTextCreator: (label: unknown) => label,
mapStateToProps(state, { includeTime }) {
const newState: ExtraControlProps = {};
const { datasource } = state;
if (datasource?.columns[0]?.hasOwnProperty('groupby')) {
const options = (datasource as Dataset).columns.filter(c => c.groupby);
if (includeTime) options.unshift(DATASET_TIME_COLUMN_OPTION);
newState.options = options;
} else {
const options = (datasource as QueryResponse).columns;
if (includeTime) options.unshift(QUERY_TIME_COLUMN_OPTION);
newState.options = options;
}
return newState;
},
commaChoosesOption: false,
};
const metrics: SharedControlConfig<'MetricsControl'> = {
type: 'MetricsControl',
multi: true,
label: t('Metrics'),
validators: [validateNonEmpty],
mapStateToProps: ({ datasource }) => ({
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
datasource,
datasourceType: datasource?.type,
}),
description: t('One or many metrics to display'),
};
const metric: SharedControlConfig<'MetricsControl'> = {
...metrics,
multi: false,
label: t('Metric'),
description: t('Metric'),
};
const datasourceControl: SharedControlConfig<'DatasourceControl'> = {
type: 'DatasourceControl',
label: t('Datasource'),
@ -203,13 +124,6 @@ const color_picker: SharedControlConfig<'ColorPickerControl'> = {
renderTrigger: true,
};
const metric_2: SharedControlConfig<'MetricsControl'> = {
...metric,
label: t('Right Axis Metric'),
clearable: true,
description: t('Choose a metric for right axis'),
};
const linear_color_scheme: SharedControlConfig<'ColorSchemeControl'> = {
type: 'ColorSchemeControl',
label: t('Linear Color Scheme'),
@ -229,20 +143,6 @@ const linear_color_scheme: SharedControlConfig<'ColorSchemeControl'> = {
}),
};
const secondary_metric: SharedControlConfig<'MetricsControl'> = {
...metric,
label: t('Color Metric'),
default: null,
validators: [],
description: t('A metric to use for color'),
};
const columnsControl: typeof groupByControl = {
...groupByControl,
label: t('Columns'),
description: t('One or many columns to pivot as columns'),
};
const granularity: SharedControlConfig<'SelectControl'> = {
type: 'SelectControl',
freeForm: true,
@ -273,44 +173,6 @@ const granularity: SharedControlConfig<'SelectControl'> = {
),
};
const granularity_sqla: SharedControlConfig<'SelectControl', ColumnMeta> = {
type: 'SelectControl',
label: TIME_FILTER_LABELS.granularity_sqla,
description: t(
'The time column for the visualization. Note that you ' +
'can define arbitrary expression that return a DATETIME ' +
'column in the table. Also note that the ' +
'filter below is applied against this column or ' +
'expression',
),
default: (c: Control) => c.default,
clearable: false,
optionRenderer: c => <ColumnOption showType column={c} />,
valueRenderer: c => <ColumnOption column={c} />,
valueKey: 'column_name',
mapStateToProps: state => {
const props: Partial<SelectControlConfig<ColumnMeta | QueryColumn>> = {};
const { datasource } = state;
if (datasource?.hasOwnProperty('main_dttm_col')) {
const dataset = datasource as Dataset;
props.options = dataset.columns.filter((c: ColumnMeta) => c.is_dttm);
props.default = null;
if (dataset.main_dttm_col) {
props.default = dataset.main_dttm_col;
} else if (props?.options) {
props.default = (props.options[0] as ColumnMeta)?.column_name;
}
} else {
const sortedQueryColumns = (datasource as QueryResponse)?.columns?.sort(
query => (query?.is_dttm ? -1 : 1),
);
props.options = sortedQueryColumns;
if (props?.options) props.default = props.options[0]?.name;
}
return props;
},
};
const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
type: 'SelectControl',
label: TIME_FILTER_LABELS.time_grain_sqla,
@ -411,64 +273,6 @@ const series_limit: SharedControlConfig<'SelectControl'> = {
),
};
const sort_by: SharedControlConfig<'MetricsControl'> = {
type: 'MetricsControl',
label: t('Sort by'),
default: null,
description: t(
'Metric used to define how the top series are sorted if a series or row limit is present. ' +
'If undefined reverts to the first metric (where appropriate).',
),
mapStateToProps: ({ datasource }) => ({
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
datasource,
datasourceType: datasource?.type,
}),
};
const series: typeof groupByControl = {
...groupByControl,
label: t('Dimensions'),
multi: false,
default: null,
description: t(
'Defines the grouping of entities. ' +
'Each series is shown as a specific color on the chart and ' +
'has a legend toggle',
),
};
const entity: typeof groupByControl = {
...groupByControl,
label: t('Entity'),
default: null,
multi: false,
validators: [validateNonEmpty],
description: t('This defines the element to be plotted on the chart'),
};
const x: SharedControlConfig<'MetricsControl'> = {
...metric,
label: t('X Axis'),
description: t('Metric assigned to the [X] axis'),
default: null,
};
const y: SharedControlConfig<'MetricsControl'> = {
...metric,
label: t('Y Axis'),
default: null,
description: t('Metric assigned to the [Y] axis'),
};
const size: SharedControlConfig<'MetricsControl'> = {
...metric,
label: t('Bubble Size'),
description: t('Metric used to calculate bubble size'),
default: null,
};
const y_axis_format: SharedControlConfig<'SelectControl', SelectDefaultOption> =
{
type: 'SelectControl',
@ -507,23 +311,6 @@ const x_axis_time_format: SharedControlConfig<
option.label.includes(search) || option.value.includes(search),
};
const adhoc_filters: SharedControlConfig<'AdhocFilterControl'> = {
type: 'AdhocFilterControl',
label: t('Filters'),
default: [],
description: '',
mapStateToProps: ({ datasource, form_data }) => ({
columns: datasource?.columns[0]?.hasOwnProperty('filterable')
? (datasource as Dataset)?.columns.filter(c => c.filterable)
: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
// current active adhoc metrics
selectedMetrics:
form_data.metrics || (form_data.metric ? [form_data.metric] : []),
datasource,
}),
};
const color_scheme: SharedControlConfig<'ColorSchemeControl'> = {
type: 'ColorSchemeControl',
label: t('Color Scheme'),
@ -551,51 +338,40 @@ const show_empty_columns: SharedControlConfig<'CheckboxControl'> = {
description: t('Show empty columns'),
};
const x_axis: SharedControlConfig<'SelectControl', ColumnMeta> = {
...groupByControl,
...xAxisControlConfig,
};
const enableExploreDnd = isFeatureEnabled(
FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP,
);
const sharedControls = {
metrics: enableExploreDnd ? dnd_adhoc_metrics : metrics,
metric: enableExploreDnd ? dnd_adhoc_metric : metric,
export default {
metrics: dndAdhocMetricsControl,
metric: dndAdhocMetricControl,
datasource: datasourceControl,
viz_type,
color_picker,
metric_2: enableExploreDnd ? dnd_adhoc_metric_2 : metric_2,
metric_2: dndAdhocMetricControl2,
linear_color_scheme,
secondary_metric: enableExploreDnd ? dnd_secondary_metric : secondary_metric,
groupby: enableExploreDnd ? dndGroupByControl : groupByControl,
columns: enableExploreDnd ? dndColumnsControl : columnsControl,
secondary_metric: dndSecondaryMetricControl,
groupby: dndGroupByControl,
columns: dndColumnsControl,
granularity,
granularity_sqla: enableExploreDnd ? dnd_granularity_sqla : granularity_sqla,
granularity_sqla: dndGranularitySqlaControl,
time_grain_sqla,
time_range,
row_limit,
limit,
timeseries_limit_metric: enableExploreDnd ? dnd_sort_by : sort_by,
orderby: enableExploreDnd ? dnd_sort_by : sort_by,
timeseries_limit_metric: dndSortByControl,
orderby: dndSortByControl,
order_desc,
series: enableExploreDnd ? dndSeries : series,
entity: enableExploreDnd ? dndEntity : entity,
x: enableExploreDnd ? dnd_x : x,
y: enableExploreDnd ? dnd_y : y,
size: enableExploreDnd ? dnd_size : size,
series: dndSeriesControl,
entity: dndEntityControl,
x: dndXControl,
y: dndYControl,
size: dndSizeControl,
y_axis_format,
x_axis_time_format,
adhoc_filters: enableExploreDnd ? dnd_adhoc_filters : adhoc_filters,
adhoc_filters: dndAdhocFilterControl,
color_scheme,
series_columns: enableExploreDnd ? dndColumnsControl : columnsControl,
series_columns: dndColumnsControl,
series_limit,
series_limit_metric: enableExploreDnd ? dnd_sort_by : sort_by,
legacy_order_by: enableExploreDnd ? dnd_sort_by : sort_by,
series_limit_metric: dndSortByControl,
legacy_order_by: dndSortByControl,
truncate_metric,
x_axis: enableExploreDnd ? dnd_x_axis : x_axis,
x_axis: dndXAxisControl,
show_empty_columns,
};
export { sharedControls, dndEntity, dndColumnsControl };

View File

@ -29,7 +29,7 @@ import type {
QueryFormMetric,
QueryResponse,
} from '@superset-ui/core';
import { sharedControls } from './shared-controls';
import sharedControls from './shared-controls';
import sharedControlComponents from './shared-controls/components';
export type { Metric } from '@superset-ui/core';
@ -238,7 +238,7 @@ export interface BaseControlConfig<
) => boolean;
mapStateToProps?: (
state: ControlPanelState,
controlState: ControlState,
controlState?: ControlState,
// TODO: add strict `chartState` typing (see superset-frontend/src/explore/types)
chartState?: AnyDict,
) => ExtraControlProps;

View File

@ -17,7 +17,7 @@
* under the License.
*/
import React, { ReactElement } from 'react';
import { sharedControls } from '../shared-controls';
import sharedControls from '../shared-controls';
import sharedControlComponents from '../shared-controls/components';
import {
ControlType,

View File

@ -30,7 +30,7 @@ import {
formatSelectOptions,
formatSelectOptionsForRange,
sections,
dndEntity,
sharedControls,
getStandardizedControls,
} from '@superset-ui/chart-controls';
@ -52,7 +52,7 @@ const allColumns = {
};
const dndAllColumns = {
...dndEntity,
...sharedControls.entity,
description: t('Columns to display'),
};

View File

@ -16,44 +16,28 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
FeatureFlag,
isFeatureEnabled,
t,
validateNonEmpty,
} from '@superset-ui/core';
import { t, validateNonEmpty } from '@superset-ui/core';
import {
columnChoices,
ControlPanelConfig,
ControlPanelState,
formatSelectOptions,
sections,
dndColumnsControl,
getStandardizedControls,
sharedControls,
} from '@superset-ui/chart-controls';
const allColumns = {
type: 'SelectControl',
const columnsConfig = {
...sharedControls.columns,
label: t('Columns'),
default: null,
description: t('Select the numeric columns to draw the histogram'),
mapStateToProps: (state: ControlPanelState) => ({
...(sharedControls.columns.mapStateToProps?.(state) || {}),
choices: columnChoices(state.datasource),
}),
multi: true,
validators: [validateNonEmpty],
};
const dndAllColumns = {
...dndColumnsControl,
description: t('Select the numeric columns to draw the histogram'),
validators: [validateNonEmpty],
};
const columnsConfig = isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP)
? dndAllColumns
: allColumns;
const config: ControlPanelConfig = {
controlPanelSections: [
sections.legacyRegularTime,

View File

@ -23,7 +23,7 @@ import {
ControlPanelState,
formatSelectOptions,
sections,
dndEntity,
sharedControls,
getStandardizedControls,
} from '@superset-ui/chart-controls';
@ -36,7 +36,7 @@ const allColumns = {
};
const columnsConfig = isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP)
? dndEntity
? sharedControls.entity
: allColumns;
const colorChoices = [

View File

@ -18,12 +18,12 @@
*/
import { t } from '@superset-ui/core';
import { dndEntity } from '@superset-ui/chart-controls';
import { sharedControls } from '@superset-ui/chart-controls';
export const dndLineColumn = {
name: 'line_column',
config: {
...dndEntity,
...sharedControls.entity,
label: t('Lines column'),
description: t('The database columns that contains lines information'),
},
@ -32,7 +32,7 @@ export const dndLineColumn = {
export const dndGeojsonColumn = {
name: 'geojson',
config: {
...dndEntity,
...sharedControls.entity,
label: t('GeoJson Column'),
description: t('Select the geojson column'),
},

View File

@ -64,7 +64,7 @@ const config: ControlPanelConfig = {
controlState,
) || {};
timeserieslimitProps.value = state.controls?.limit?.value
? controlState.value
? controlState?.value
: [];
return timeserieslimitProps;
},

View File

@ -345,7 +345,7 @@ const config: ControlPanelConfig = {
controlState,
) || {};
timeserieslimitProps.value = state.controls?.limit?.value
? controlState.value
? controlState?.value
: [];
return timeserieslimitProps;
},

View File

@ -49,7 +49,7 @@ const allColumns: typeof sharedControls.groupby = {
options: datasource?.columns || [],
queryMode: getQueryMode(controls),
externalValidationErrors:
isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0
isRawMode({ controls }) && ensureIsArray(controlState?.value).length === 0
? [t('must have a value')]
: [],
}),
@ -74,7 +74,7 @@ const dndAllColumns: typeof sharedControls.groupby = {
}
newState.queryMode = getQueryMode(controls);
newState.externalValidationErrors =
isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0
isRawMode({ controls }) && ensureIsArray(controlState?.value).length === 0
? [t('must have a value')]
: [];
return newState;

View File

@ -46,7 +46,7 @@ const percentMetrics: typeof sharedControls.metrics = {
externalValidationErrors: validateAggControlValues(controls, [
controls.groupby?.value,
controls.metrics?.value,
controlState.value,
controlState?.value,
]),
}),
rerender: ['groupby', 'metrics'],

View File

@ -40,7 +40,6 @@ import {
sections,
sharedControls,
ControlPanelState,
ExtraControlProps,
ControlState,
emitFilterControl,
Dataset,
@ -96,15 +95,14 @@ const queryMode: ControlConfig<'RadioButtonControl'> = {
rerender: ['all_columns', 'groupby', 'metrics', 'percent_metrics'],
};
const all_columns: typeof sharedControls.groupby = {
type: 'SelectControl',
const allColumnsControl: typeof sharedControls.groupby = {
...sharedControls.groupby,
label: t('Columns'),
description: t('Columns to display'),
multi: true,
freeForm: true,
allowAll: true,
commaChoosesOption: false,
default: [],
optionRenderer: c => <ColumnOption showType column={c} />,
valueRenderer: c => <ColumnOption column={c} />,
valueKey: 'column_name',
@ -112,7 +110,7 @@ const all_columns: typeof sharedControls.groupby = {
options: datasource?.columns || [],
queryMode: getQueryMode(controls),
externalValidationErrors:
isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0
isRawMode({ controls }) && ensureIsArray(controlState?.value).length === 0
? [t('must have a value')]
: [],
}),
@ -120,37 +118,12 @@ const all_columns: typeof sharedControls.groupby = {
resetOnHide: false,
};
const dnd_all_columns: typeof sharedControls.groupby = {
type: 'DndColumnSelect',
label: t('Columns'),
description: t('Columns to display'),
default: [],
mapStateToProps({ datasource, controls }, controlState) {
const newState: ExtraControlProps = {};
if (datasource?.columns[0]?.hasOwnProperty('column_name')) {
const options = (datasource as Dataset).columns;
newState.options = Object.fromEntries(
options.map((option: ColumnMeta) => [option.column_name, option]),
);
} else newState.options = datasource?.columns;
newState.queryMode = getQueryMode(controls);
newState.externalValidationErrors =
isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0
? [t('must have a value')]
: [];
return newState;
},
visibility: isRawMode,
resetOnHide: false,
};
const percent_metrics: typeof sharedControls.metrics = {
type: 'MetricsControl',
const percentMetricsControl: typeof sharedControls.metrics = {
...sharedControls.metrics,
label: t('Percentage metrics'),
description: t(
'Metrics for which percentage of total are to be displayed. Calculated from only data within the row limit.',
),
multi: true,
visibility: isAggMode,
resetOnHide: false,
mapStateToProps: ({ datasource, controls }, controlState) => ({
@ -162,7 +135,7 @@ const percent_metrics: typeof sharedControls.metrics = {
externalValidationErrors: validateAggControlValues(controls, [
controls.groupby?.value,
controls.metrics?.value,
controlState.value,
controlState?.value,
]),
}),
rerender: ['groupby', 'metrics'],
@ -170,11 +143,6 @@ const percent_metrics: typeof sharedControls.metrics = {
validators: [],
};
const dnd_percent_metrics = {
...percent_metrics,
type: 'DndMetricSelect',
};
const config: ControlPanelConfig = {
controlPanelSections: [
sections.legacyTimeseriesTime,
@ -251,19 +219,13 @@ const config: ControlPanelConfig = {
},
{
name: 'all_columns',
config: isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP)
? dnd_all_columns
: all_columns,
config: allColumnsControl,
},
],
[
{
name: 'percent_metrics',
config: {
...(isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP)
? dnd_percent_metrics
: percent_metrics),
},
config: percentMetricsControl,
},
],
['adhoc_filters'],

View File

@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FeatureFlag } from '@superset-ui/core';
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import {
@ -27,12 +28,18 @@ const defaultProps: DndColumnSelectProps = {
type: 'DndColumnSelect',
name: 'Filter',
onChange: jest.fn(),
options: {
string: { column_name: 'Column A' },
},
options: [{ column_name: 'Column A' }],
actions: { setControlValue: jest.fn() },
};
beforeAll(() => {
window.featureFlags = { [FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP]: true };
});
afterAll(() => {
window.featureFlags = {};
});
test('renders with default props', async () => {
render(<DndColumnSelect {...defaultProps} />, {
useDnd: true,
@ -42,7 +49,7 @@ test('renders with default props', async () => {
});
test('renders with value', async () => {
render(<DndColumnSelect {...defaultProps} value="string" />, {
render(<DndColumnSelect {...defaultProps} value="Column A" />, {
useDnd: true,
useRedux: true,
});

View File

@ -24,7 +24,11 @@ import {
tn,
QueryFormColumn,
} from '@superset-ui/core';
import { ColumnMeta, isColumnMeta } from '@superset-ui/chart-controls';
import {
ColumnMeta,
isColumnMeta,
withDndFallback,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash';
import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper';
@ -34,13 +38,14 @@ import { DndItemType } from 'src/explore/components/DndItemType';
import { useComponentDidUpdate } from 'src/hooks/useComponentDidUpdate';
import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger';
import { DndControlProps } from './types';
import SelectControl from '../SelectControl';
export type DndColumnSelectProps = DndControlProps<QueryFormColumn> & {
options: Record<string, ColumnMeta>;
options: ColumnMeta[];
isTemporal?: boolean;
};
export function DndColumnSelect(props: DndColumnSelectProps) {
function DndColumnSelect(props: DndColumnSelectProps) {
const {
value,
options,
@ -48,16 +53,20 @@ export function DndColumnSelect(props: DndColumnSelectProps) {
onChange,
canDelete = true,
ghostButtonText,
clickEnabledGhostButtonText,
name,
label,
isTemporal,
} = props;
const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false);
const optionSelector = useMemo(
() => new OptionSelector(options, multi, value),
[multi, options, value],
);
const optionSelector = useMemo(() => {
const optionsMap = Object.fromEntries(
options.map(option => [option.column_name, option]),
);
return new OptionSelector(optionsMap, multi, value);
}, [multi, options, value]);
// synchronize values in case of dataset changes
const handleOptionsChange = useCallback(() => {
@ -126,15 +135,18 @@ export function DndColumnSelect(props: DndColumnSelectProps) {
[onChange, optionSelector],
);
const popoverOptions = useMemo(() => Object.values(options), [options]);
const clickEnabled = useMemo(
() => isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX),
[],
);
const valuesRenderer = useCallback(
() =>
optionSelector.values.map((column, idx) =>
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX) ? (
clickEnabled ? (
<ColumnSelectPopoverTrigger
key={idx}
columns={popoverOptions}
columns={options}
onColumnEdit={newColumn => {
if (isColumnMeta(newColumn)) {
optionSelector.replace(idx, newColumn.column_name);
@ -171,13 +183,15 @@ export function DndColumnSelect(props: DndColumnSelectProps) {
),
[
canDelete,
clickEnabled,
isTemporal,
label,
name,
onChange,
onClickClose,
onShiftOptions,
optionSelector,
popoverOptions,
options,
],
);
@ -205,15 +219,24 @@ export function DndColumnSelect(props: DndColumnSelectProps) {
togglePopover(true);
}, [togglePopover]);
const defaultGhostButtonText = isFeatureEnabled(
FeatureFlag.ENABLE_DND_WITH_CLICK_UX,
)
? tn(
'Drop a column here or click',
'Drop columns here or click',
multi ? 2 : 1,
)
: tn('Drop column here', 'Drop columns here', multi ? 2 : 1);
const labelGhostButtonText = useMemo(() => {
if (clickEnabled) {
return (
clickEnabledGhostButtonText ??
ghostButtonText ??
tn(
'Drop a column here or click',
'Drop columns here or click',
multi ? 2 : 1,
)
);
}
return (
ghostButtonText ??
tn('Drop column here', 'Drop columns here', multi ? 2 : 1)
);
}, [clickEnabled, clickEnabledGhostButtonText, ghostButtonText, multi]);
return (
<div>
@ -223,16 +246,12 @@ export function DndColumnSelect(props: DndColumnSelectProps) {
valuesRenderer={valuesRenderer}
accept={DndItemType.Column}
displayGhostButton={multi || optionSelector.values.length === 0}
ghostButtonText={ghostButtonText || defaultGhostButtonText}
onClickGhostButton={
isFeatureEnabled(FeatureFlag.ENABLE_DND_WITH_CLICK_UX)
? openPopover
: undefined
}
ghostButtonText={labelGhostButtonText}
onClickGhostButton={clickEnabled ? openPopover : undefined}
{...props}
/>
<ColumnSelectPopoverTrigger
columns={popoverOptions}
columns={options}
onColumnEdit={addNewColumnWithPopover}
isControlledComponent
togglePopover={togglePopover}
@ -245,3 +264,10 @@ export function DndColumnSelect(props: DndColumnSelectProps) {
</div>
);
}
const DndColumnSelectWithFallback = withDndFallback(
DndColumnSelect,
SelectControl,
);
export { DndColumnSelectWithFallback as DndColumnSelect };

View File

@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { GenericDataType } from '@superset-ui/core';
import { FeatureFlag, GenericDataType } from '@superset-ui/core';
import { render, screen } from 'spec/helpers/testing-library';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocFilter, {
@ -48,6 +48,14 @@ const baseFormData = {
datasource: 'table__1',
};
beforeAll(() => {
window.featureFlags = { [FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP]: true };
});
afterAll(() => {
window.featureFlags = {};
});
test('renders with default props', async () => {
render(<DndFilterSelect {...defaultProps} />, { useDnd: true });
expect(

View File

@ -27,7 +27,7 @@ import {
SupersetClient,
t,
} from '@superset-ui/core';
import { ColumnMeta } from '@superset-ui/chart-controls';
import { ColumnMeta, withDndFallback } from '@superset-ui/chart-controls';
import {
OPERATOR_ENUM_TO_OPERATOR_TYPE,
Operators,
@ -49,6 +49,7 @@ import {
} from 'src/explore/components/DatasourcePanel/types';
import { DndItemType } from 'src/explore/components/DndItemType';
import { ControlComponentProps } from 'src/explore/components/Control';
import AdhocFilterControl from '../FilterControl/AdhocFilterControl';
const EMPTY_OBJECT = {};
const DND_ACCEPTED_TYPES = [
@ -69,7 +70,7 @@ export interface DndFilterSelectProps
datasource: Datasource;
}
export const DndFilterSelect = (props: DndFilterSelectProps) => {
const DndFilterSelect = (props: DndFilterSelectProps) => {
const { datasource, onChange = () => {}, name: controlName } = props;
const propsValues = Array.from(props.value ?? []);
@ -407,3 +408,10 @@ export const DndFilterSelect = (props: DndFilterSelectProps) => {
</>
);
};
const DndFilterSelectWithFallback = withDndFallback(
DndFilterSelect,
AdhocFilterControl,
);
export { DndFilterSelectWithFallback as DndFilterSelect };

View File

@ -18,6 +18,7 @@
*/
import React from 'react';
import userEvent from '@testing-library/user-event';
import { FeatureFlag } from '@superset-ui/core';
import {
render,
screen,
@ -67,6 +68,14 @@ const adhocMetricB = {
optionName: 'def',
};
beforeAll(() => {
window.featureFlags = { [FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP]: true };
});
afterAll(() => {
window.featureFlags = {};
});
test('renders with default props', () => {
render(<DndMetricSelect {...defaultProps} />, { useDnd: true });
expect(screen.getByText('Drop column or metric here')).toBeInTheDocument();

View File

@ -27,7 +27,7 @@ import {
QueryFormMetric,
tn,
} from '@superset-ui/core';
import { ColumnMeta } from '@superset-ui/chart-controls';
import { ColumnMeta, withDndFallback } from '@superset-ui/chart-controls';
import { isEqual } from 'lodash';
import { usePrevious } from 'src/hooks/usePrevious';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
@ -41,6 +41,7 @@ import { DndItemType } from 'src/explore/components/DndItemType';
import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
import { savedMetricType } from 'src/explore/components/controls/MetricControl/types';
import { AGGREGATES } from 'src/explore/constants';
import MetricsControl from '../MetricControl/MetricsControl';
const EMPTY_OBJECT = {};
const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric];
@ -125,7 +126,7 @@ const getMetricsMatchingCurrentDataset = (
}, []);
};
export const DndMetricSelect = (props: any) => {
const DndMetricSelect = (props: any) => {
const { onChange, multi, columns, savedMetrics } = props;
const handleChange = useCallback(
@ -408,3 +409,10 @@ export const DndMetricSelect = (props: any) => {
</div>
);
};
const DndMetricSelectWithFallback = withDndFallback(
DndMetricSelect,
MetricsControl,
);
export { DndMetricSelectWithFallback as DndMetricSelect };

View File

@ -46,6 +46,7 @@ export type DndControlProps<ValueType extends JsonValue> =
multi?: boolean;
canDelete?: boolean;
ghostButtonText?: string;
clickEnabledGhostButtonText?: string;
onChange: (value: ValueType | ValueType[] | null | undefined) => void;
};