From befe0c767378d14d0e18bb690deee913274840a6 Mon Sep 17 00:00:00 2001
From: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>
Date: Tue, 12 May 2020 17:47:41 -0700
Subject: [PATCH] feat(control-utils): add shared controls + dependencies,
convert to typescript (#459)
---
.../superset-ui-control-utils/package.json | 1 +
.../src/ColumnOption.tsx | 73 +++
.../src/ColumnTypeLabel.tsx | 53 ++
.../src/constants.ts | 29 +
.../superset-ui-control-utils/src/index.ts | 12 +
.../src/mainMetric.ts | 39 ++
.../src/shared-controls.tsx | 522 ++++++++++++++++++
.../test/ColumnOption.test.tsx | 103 ++++
.../test/ColumnTypeLabel.test.tsx | 68 +++
.../test/mainMetric.test.ts | 39 ++
10 files changed, 939 insertions(+)
create mode 100644 packages/superset-ui-control-utils/src/ColumnOption.tsx
create mode 100644 packages/superset-ui-control-utils/src/ColumnTypeLabel.tsx
create mode 100644 packages/superset-ui-control-utils/src/constants.ts
create mode 100644 packages/superset-ui-control-utils/src/mainMetric.ts
create mode 100644 packages/superset-ui-control-utils/src/shared-controls.tsx
create mode 100644 packages/superset-ui-control-utils/test/ColumnOption.test.tsx
create mode 100644 packages/superset-ui-control-utils/test/ColumnTypeLabel.test.tsx
create mode 100644 packages/superset-ui-control-utils/test/mainMetric.test.ts
diff --git a/packages/superset-ui-control-utils/package.json b/packages/superset-ui-control-utils/package.json
index cbac2f9622..a1117806eb 100644
--- a/packages/superset-ui-control-utils/package.json
+++ b/packages/superset-ui-control-utils/package.json
@@ -26,6 +26,7 @@
"access": "public"
},
"peerDependencies": {
+ "@superset-ui/color": "^0.13.3",
"@superset-ui/translation": "^0.13",
"@superset-ui/validator": "^0.13",
"react": "^16.13.1"
diff --git a/packages/superset-ui-control-utils/src/ColumnOption.tsx b/packages/superset-ui-control-utils/src/ColumnOption.tsx
new file mode 100644
index 0000000000..fabe17a9dd
--- /dev/null
+++ b/packages/superset-ui-control-utils/src/ColumnOption.tsx
@@ -0,0 +1,73 @@
+/**
+ * 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.
+ */
+/* eslint-disable camelcase */
+import React from 'react';
+
+import { ColumnTypeLabel } from './ColumnTypeLabel';
+import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
+
+export type Column = {
+ column_name: string;
+ groupby?: string;
+ verbose_name?: string;
+ description?: string;
+ expression?: string;
+ is_dttm?: boolean;
+ type?: string;
+ filterable?: boolean;
+};
+
+export type Props = {
+ column: Column;
+ showType?: boolean;
+};
+
+export function ColumnOption({ column, showType = false }: Props) {
+ const hasExpression = column.expression && column.expression !== column.column_name;
+
+ let columnType = column.type;
+ if (column.is_dttm) {
+ columnType = 'time';
+ } else if (hasExpression) {
+ columnType = 'expression';
+ }
+
+ return (
+
+ {showType && columnType && }
+ {column.verbose_name || column.column_name}
+ {column.description && (
+
+ )}
+ {hasExpression && (
+
+ )}
+
+ );
+}
diff --git a/packages/superset-ui-control-utils/src/ColumnTypeLabel.tsx b/packages/superset-ui-control-utils/src/ColumnTypeLabel.tsx
new file mode 100644
index 0000000000..fbf834c5ee
--- /dev/null
+++ b/packages/superset-ui-control-utils/src/ColumnTypeLabel.tsx
@@ -0,0 +1,53 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+
+export type Props = {
+ type: string;
+};
+
+export function ColumnTypeLabel({ type }: Props) {
+ let stringIcon = '';
+ if (typeof type !== 'string') {
+ stringIcon = '?';
+ } else if (type === '' || type === 'expression') {
+ stringIcon = 'ƒ';
+ } else if (type === 'aggregate') {
+ stringIcon = 'AGG';
+ } else if (type.match(/.*char.*/i) || type.match(/string.*/i) || type.match(/.*text.*/i)) {
+ stringIcon = 'ABC';
+ } else if (type.match(/.*int.*/i) || type === 'LONG' || type === 'DOUBLE' || type === 'FLOAT') {
+ stringIcon = '#';
+ } else if (type.match(/.*bool.*/i)) {
+ stringIcon = 'T/F';
+ } else if (type.match(/.*time.*/i)) {
+ stringIcon = 'time';
+ } else if (type.match(/unknown/i)) {
+ stringIcon = '?';
+ }
+
+ const typeIcon =
+ stringIcon === 'time' ? (
+
+ ) : (
+
{stringIcon}
+ );
+
+ return {typeIcon};
+}
diff --git a/packages/superset-ui-control-utils/src/constants.ts b/packages/superset-ui-control-utils/src/constants.ts
new file mode 100644
index 0000000000..7fdc63f7b0
--- /dev/null
+++ b/packages/superset-ui-control-utils/src/constants.ts
@@ -0,0 +1,29 @@
+/**
+ * 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 { t } from '@superset-ui/translation';
+
+// eslint-disable-next-line import/prefer-default-export
+export const TIME_FILTER_LABELS = {
+ time_range: t('Time Range'),
+ granularity_sqla: t('Time Column'),
+ time_grain_sqla: t('Time Grain'),
+ druid_time_origin: t('Origin'),
+ granularity: t('Time Granularity'),
+};
diff --git a/packages/superset-ui-control-utils/src/index.ts b/packages/superset-ui-control-utils/src/index.ts
index ad48f4417e..1d6cd32a04 100644
--- a/packages/superset-ui-control-utils/src/index.ts
+++ b/packages/superset-ui-control-utils/src/index.ts
@@ -1,6 +1,18 @@
+import * as constantsModule from './constants';
+import * as sharedControlsModule from './shared-controls';
import * as sectionModules from './sections';
+// `export * as x from 'y'` doesn't work for some reason
+export const constants = constantsModule;
+export const internalSharedControls = sharedControlsModule;
export const sections = sectionModules;
export { D3_FORMAT_DOCS, D3_FORMAT_OPTIONS, D3_TIME_FORMAT_OPTIONS } from './D3Formatting';
export { formatSelectOptions, formatSelectOptionsForRange } from './selectOptions';
export { default as InfoTooltipWithTrigger } from './InfoTooltipWithTrigger';
+export {
+ ColumnOption,
+ Props as ColumnOptionProps,
+ Column as ColumnOptionColumn,
+} from './ColumnOption';
+export { ColumnTypeLabel, Props as ColumnTypeLabelProps } from './ColumnTypeLabel';
+export { mainMetric, Metric } from './mainMetric';
diff --git a/packages/superset-ui-control-utils/src/mainMetric.ts b/packages/superset-ui-control-utils/src/mainMetric.ts
new file mode 100644
index 0000000000..cafa12e668
--- /dev/null
+++ b/packages/superset-ui-control-utils/src/mainMetric.ts
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ */
+/* eslint-disable camelcase */
+
+export type Metric = {
+ metric_name: string;
+};
+
+export function mainMetric(savedMetrics?: Metric[] | null) {
+ // Using 'count' as default metric if it exists, otherwise using whatever one shows up first
+ let metric;
+ if (savedMetrics && savedMetrics.length > 0) {
+ savedMetrics.forEach(m => {
+ if (m.metric_name === 'count') {
+ metric = 'count';
+ }
+ });
+ if (!metric) {
+ metric = savedMetrics[0].metric_name;
+ }
+ }
+ return metric;
+}
diff --git a/packages/superset-ui-control-utils/src/shared-controls.tsx b/packages/superset-ui-control-utils/src/shared-controls.tsx
new file mode 100644
index 0000000000..a2e38fde76
--- /dev/null
+++ b/packages/superset-ui-control-utils/src/shared-controls.tsx
@@ -0,0 +1,522 @@
+/**
+ * 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.
+ */
+
+/* eslint-disable camelcase */
+
+/**
+ * This file exports all controls available for use in chart plugins internal to Superset.
+ * It is not recommended to use the controls here for any third-party plugins.
+ *
+ * While the React components located in `controls/components` represent different
+ * types of controls (CheckboxControl, SelectControl, TextControl, ...), the controls here
+ * represent instances of control types, that can be reused across visualization types.
+ *
+ * When controls are reused across viz types, their values are carried over as a user
+ * changes the chart types.
+ *
+ * While the keys defined in the control itself get passed to the controlType as props,
+ * here's a list of the keys that are common to all controls, and as a result define the
+ * control interface:
+ *
+ * - type: the control type, referencing a React component of the same name
+ * - label: the label as shown in the control's header
+ * - description: shown in the info tooltip of the control's header
+ * - default: the default value when opening a new chart, or changing visualization type
+ * - renderTrigger: a bool that defines whether the visualization should be re-rendered
+ when changed. This should `true` for controls that only affect the rendering (client side)
+ and don't affect the query or backend data processing as those require to re run a query
+ and fetch the data
+ * - validators: an array of functions that will receive the value of the component and
+ should return error messages when the value is not valid. The error message gets
+ bubbled up to the control header, section header and query panel header.
+ * - warning: text shown as a tooltip on a warning icon in the control's header
+ * - error: text shown as a tooltip on a error icon in the control's header
+ * - mapStateToProps: a function that receives the App's state and return an object of k/v
+ to overwrite configuration at runtime. This is useful to alter a component based on
+ anything external to it, like another control's value. For instance it's possible to
+ show a warning based on the value of another component. It's also possible to bind
+ arbitrary data from the redux store to the component this way.
+ * - tabOverride: set to 'data' if you want to force a renderTrigger to show up on the `Data`
+ tab, otherwise `renderTrigger: true` components will show up on the `Style` tab.
+ *
+ * Note that the keys defined in controls in this file that are not listed above represent
+ * props specific for the React component defined as `type`. Also note that this module work
+ * in tandem with `controlPanels/index.js` that defines how controls are composed into sections for
+ * each and every visualization type.
+ */
+import React from 'react';
+import { t } from '@superset-ui/translation';
+import {
+ getCategoricalSchemeRegistry,
+ getSequentialSchemeRegistry,
+ SequentialScheme,
+} from '@superset-ui/color';
+import { legacyValidateInteger, validateNonEmpty } from '@superset-ui/validator';
+
+import { formatSelectOptions } from './selectOptions';
+import { mainMetric, Metric } from './mainMetric';
+import { ColumnOption, Column as ColumnOptionColumn } from './ColumnOption';
+import { TIME_FILTER_LABELS } from './constants';
+
+const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
+const sequentialSchemeRegistry = getSequentialSchemeRegistry();
+
+export const PRIMARY_COLOR = { r: 0, g: 122, b: 135, a: 1 };
+
+// input choices & options
+export const D3_FORMAT_OPTIONS = [
+ ['SMART_NUMBER', 'Adaptative formating'],
+ ['~g', 'Original value'],
+ [',d', ',d (12345.432 => 12,345)'],
+ ['.1s', '.1s (12345.432 => 10k)'],
+ ['.3s', '.3s (12345.432 => 12.3k)'],
+ [',.1%', ',.1% (12345.432 => 1,234,543.2%)'],
+ ['.3%', '.3% (12345.432 => 1234543.200%)'],
+ ['.4r', '.4r (12345.432 => 12350)'],
+ [',.3f', ',.3f (12345.432 => 12,345.432)'],
+ ['+,', '+, (12345.432 => +12,345.432)'],
+ ['$,.2f', '$,.2f (12345.432 => $12,345.43)'],
+ ['DURATION', 'Duration in ms (66000 => 1m 6s)'],
+ ['DURATION_SUB', 'Duration in ms (100.40008 => 100ms 400µs 80ns)'],
+];
+
+const ROW_LIMIT_OPTIONS = [10, 50, 100, 250, 500, 1000, 5000, 10000, 50000];
+
+const SERIES_LIMITS = [0, 5, 10, 25, 50, 100, 500];
+
+export const D3_FORMAT_DOCS = 'D3 format syntax: https://github.com/d3/d3-format';
+
+export const D3_TIME_FORMAT_OPTIONS = [
+ ['smart_date', 'Adaptative formating'],
+ ['%d/%m/%Y', '%d/%m/%Y | 14/01/2019'],
+ ['%m/%d/%Y', '%m/%d/%Y | 01/14/2019'],
+ ['%Y-%m-%d', '%Y-%m-%d | 2019-01-14'],
+ ['%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M:%S | 2019-01-14 01:32:10'],
+ ['%d-%m-%Y %H:%M:%S', '%Y-%m-%d %H:%M:%S | 14-01-2019 01:32:10'],
+ ['%H:%M:%S', '%H:%M:%S | 01:32:10'],
+];
+
+const timeColumnOption = {
+ verbose_name: 'Time',
+ column_name: '__timestamp',
+ description: t('A reference to the [Time] configuration, taking granularity into account'),
+};
+
+type StateDatasource = {
+ columns: ColumnOptionColumn[];
+ metrics: unknown[];
+ type: unknown;
+ main_dttm_col: unknown;
+ time_grain_sqla: unknown;
+};
+
+type State = {
+ form_data: { [key: string]: unknown };
+ datasource?: StateDatasource | null;
+ options?: ColumnOptionColumn[];
+ controls?: {
+ comparison_type?: {
+ value: string;
+ };
+ };
+};
+
+type Control = {
+ savedMetrics?: Metric[] | null;
+ default?: unknown;
+};
+
+const groupByControl = {
+ type: 'SelectControl',
+ controlGroup: 'groupby',
+ multi: true,
+ freeForm: true,
+ label: t('Group by'),
+ default: [],
+ includeTime: false,
+ description: t('One or many controls to group by'),
+ optionRenderer: (c: ColumnOptionColumn) => ,
+ valueRenderer: (c: ColumnOptionColumn) => ,
+ valueKey: 'column_name',
+ allowAll: true,
+ filterOption: (opt: ColumnOptionColumn, text: string) =>
+ (opt.column_name && opt.column_name.toLowerCase().includes(text.toLowerCase())) ||
+ (opt.verbose_name && opt.verbose_name.toLowerCase().includes(text.toLowerCase())),
+ promptTextCreator: (label: unknown) => label,
+ mapStateToProps: (state: State, control?: { includeTime: boolean }) => {
+ const newState: State = {} as any;
+ if (state.datasource) {
+ newState.options = state.datasource.columns.filter(c => c.groupby);
+ if (control?.includeTime) {
+ newState.options.push(timeColumnOption);
+ }
+ }
+ return newState;
+ },
+ commaChoosesOption: false,
+};
+
+const metrics = {
+ type: 'MetricsControl',
+ controlGroup: 'metrics',
+ multi: true,
+ label: t('Metrics'),
+ validators: [validateNonEmpty],
+ default: (c: Control) => {
+ const metric = mainMetric(c.savedMetrics);
+ return metric ? [metric] : null;
+ },
+ mapStateToProps: ({ datasource }: State) => {
+ return {
+ columns: datasource ? datasource.columns : [],
+ savedMetrics: datasource ? datasource.metrics : [],
+ datasourceType: datasource?.type,
+ };
+ },
+ description: t('One or many metrics to display'),
+};
+const metric = {
+ ...metrics,
+ multi: false,
+ label: t('Metric'),
+ description: t('Metric'),
+ default: (c: Control) => mainMetric(c.savedMetrics),
+};
+
+export function columnChoices(datasource: StateDatasource) {
+ if (datasource?.columns) {
+ return datasource.columns
+ .map(col => [col.column_name, col.verbose_name || col.column_name])
+ .sort((opt1, opt2) => (opt1[1].toLowerCase() > opt2[1].toLowerCase() ? 1 : -1));
+ }
+ return [];
+}
+
+export const controls = {
+ metrics,
+
+ metric,
+
+ datasource: {
+ type: 'DatasourceControl',
+ label: t('Datasource'),
+ default: null,
+ description: null,
+ mapStateToProps: (state: State, control: unknown, actions: { setDatasource: unknown }) => ({
+ datasource: state.datasource,
+ onDatasourceSave: actions ? actions.setDatasource : () => {},
+ }),
+ },
+
+ viz_type: {
+ type: 'VizTypeControl',
+ label: t('Visualization Type'),
+ default: 'table',
+ description: t('The type of visualization to display'),
+ },
+
+ color_picker: {
+ label: t('Fixed Color'),
+ description: t('Use this to define a static color for all circles'),
+ type: 'ColorPickerControl',
+ default: PRIMARY_COLOR,
+ renderTrigger: true,
+ },
+
+ metric_2: {
+ ...metric,
+ label: t('Right Axis Metric'),
+ clearable: true,
+ description: t('Choose a metric for right axis'),
+ },
+
+ linear_color_scheme: {
+ type: 'ColorSchemeControl',
+ label: t('Linear Color Scheme'),
+ choices: () =>
+ (sequentialSchemeRegistry.values() as SequentialScheme[]).map(value => [
+ value.id,
+ value.label,
+ ]),
+ default: sequentialSchemeRegistry.getDefaultKey(),
+ clearable: false,
+ description: '',
+ renderTrigger: true,
+ schemes: () => sequentialSchemeRegistry.getMap(),
+ isLinear: true,
+ },
+
+ secondary_metric: {
+ ...metric,
+ label: t('Color Metric'),
+ default: null,
+ validators: [],
+ description: t('A metric to use for color'),
+ },
+
+ groupby: groupByControl,
+
+ columns: {
+ ...groupByControl,
+ label: t('Columns'),
+ description: t('One or many controls to pivot as columns'),
+ },
+
+ druid_time_origin: {
+ type: 'SelectControl',
+ freeForm: true,
+ label: TIME_FILTER_LABELS.druid_time_origin,
+ choices: [
+ ['', 'default'],
+ ['now', 'now'],
+ ],
+ default: null,
+ description: t(
+ 'Defines the origin where time buckets start, ' +
+ 'accepts natural dates as in `now`, `sunday` or `1970-01-01`',
+ ),
+ },
+
+ granularity: {
+ type: 'SelectControl',
+ freeForm: true,
+ label: TIME_FILTER_LABELS.granularity,
+ default: 'one day',
+ choices: [
+ [null, 'all'],
+ ['PT5S', '5 seconds'],
+ ['PT30S', '30 seconds'],
+ ['PT1M', '1 minute'],
+ ['PT5M', '5 minutes'],
+ ['PT30M', '30 minutes'],
+ ['PT1H', '1 hour'],
+ ['PT6H', '6 hour'],
+ ['P1D', '1 day'],
+ ['P7D', '7 days'],
+ ['P1W', 'week'],
+ ['week_starting_sunday', 'week starting Sunday'],
+ ['week_ending_saturday', 'week ending Saturday'],
+ ['P1M', 'month'],
+ ['P3M', 'quarter'],
+ ['P1Y', 'year'],
+ ],
+ description: t(
+ 'The time granularity for the visualization. Note that you ' +
+ 'can type and use simple natural language as in `10 seconds`, ' +
+ '`1 day` or `56 weeks`',
+ ),
+ },
+
+ granularity_sqla: {
+ 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: ColumnOptionColumn) => ,
+ valueRenderer: (c: ColumnOptionColumn) => ,
+ valueKey: 'column_name',
+ mapStateToProps: (state: State) => {
+ const props: any = {};
+ if (state.datasource) {
+ props.options = state.datasource.columns.filter(c => c.is_dttm);
+ props.default = null;
+ if (state.datasource.main_dttm_col) {
+ props.default = state.datasource.main_dttm_col;
+ } else if (props.options && props.options.length > 0) {
+ props.default = props.options[0].column_name;
+ }
+ }
+ return props;
+ },
+ },
+
+ time_grain_sqla: {
+ type: 'SelectControl',
+ label: TIME_FILTER_LABELS.time_grain_sqla,
+ default: 'P1D',
+ description: t(
+ 'The time granularity for the visualization. This ' +
+ 'applies a date transformation to alter ' +
+ 'your time column and defines a new time granularity. ' +
+ 'The options here are defined on a per database ' +
+ 'engine basis in the Superset source code.',
+ ),
+ mapStateToProps: (state: State) => ({
+ choices: state.datasource ? state.datasource.time_grain_sqla : null,
+ }),
+ },
+
+ time_range: {
+ type: 'DateFilterControl',
+ freeForm: true,
+ label: TIME_FILTER_LABELS.time_range,
+ default: t('Last week'), // this value is translated, but the backend wouldn't understand a translated value?
+ description: t(
+ 'The time range for the visualization. All relative times, e.g. "Last month", ' +
+ '"Last 7 days", "now", etc. are evaluated on the server using the server\'s ' +
+ 'local time (sans timezone). All tooltips and placeholder times are expressed ' +
+ 'in UTC (sans timezone). The timestamps are then evaluated by the database ' +
+ "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: State) => ({
+ endpoints: state.form_data ? state.form_data.time_range_endpoints : null,
+ }),
+ },
+
+ row_limit: {
+ type: 'SelectControl',
+ freeForm: true,
+ label: t('Row limit'),
+ validators: [legacyValidateInteger],
+ default: 10000,
+ choices: formatSelectOptions(ROW_LIMIT_OPTIONS),
+ },
+
+ limit: {
+ type: 'SelectControl',
+ freeForm: true,
+ label: t('Series limit'),
+ validators: [legacyValidateInteger],
+ choices: formatSelectOptions(SERIES_LIMITS),
+ description: t(
+ 'Limits the number of time series that get displayed. A sub query ' +
+ '(or an extra phase where sub queries are not supported) is applied to limit ' +
+ 'the number of time series that get fetched and displayed. This feature is useful ' +
+ 'when grouping by high cardinality dimension(s).',
+ ),
+ },
+
+ timeseries_limit_metric: {
+ type: 'MetricsControl',
+ label: t('Sort By'),
+ default: null,
+ description: t('Metric used to define the top series'),
+ mapStateToProps: (state: State) => ({
+ columns: state.datasource ? state.datasource.columns : [],
+ savedMetrics: state.datasource ? state.datasource.metrics : [],
+ datasourceType: state.datasource && state.datasource.type,
+ }),
+ },
+
+ series: {
+ ...groupByControl,
+ label: t('Series'),
+ 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',
+ ),
+ },
+
+ entity: {
+ ...groupByControl,
+ label: t('Entity'),
+ default: null,
+ multi: false,
+ validators: [validateNonEmpty],
+ description: t('This defines the element to be plotted on the chart'),
+ },
+
+ x: {
+ ...metric,
+ label: t('X Axis'),
+ description: t('Metric assigned to the [X] axis'),
+ default: null,
+ },
+
+ y: {
+ ...metric,
+ label: t('Y Axis'),
+ default: null,
+ description: t('Metric assigned to the [Y] axis'),
+ },
+
+ size: {
+ ...metric,
+ label: t('Bubble Size'),
+ default: null,
+ },
+
+ y_axis_format: {
+ type: 'SelectControl',
+ freeForm: true,
+ label: t('Y Axis Format'),
+ renderTrigger: true,
+ default: 'SMART_NUMBER',
+ choices: D3_FORMAT_OPTIONS,
+ description: D3_FORMAT_DOCS,
+ mapStateToProps: (state: State) => {
+ const showWarning = state.controls?.comparison_type?.value === 'percentage';
+ return {
+ warning: showWarning
+ ? t(
+ 'When `Calculation type` is set to "Percentage change", the Y ' +
+ 'Axis Format is forced to `.1%`',
+ )
+ : null,
+ disabled: showWarning,
+ };
+ },
+ },
+
+ adhoc_filters: {
+ type: 'AdhocFilterControl',
+ label: t('Filters'),
+ default: null,
+ description: '',
+ mapStateToProps: (state: State) => ({
+ columns: state.datasource?.columns.filter(c => c.filterable) || [],
+ savedMetrics: state.datasource?.metrics || [],
+ datasource: state.datasource,
+ }),
+ provideFormDataToProps: true,
+ },
+
+ color_scheme: {
+ type: 'ColorSchemeControl',
+ label: t('Color Scheme'),
+ default: categoricalSchemeRegistry.getDefaultKey(),
+ renderTrigger: true,
+ choices: () => categoricalSchemeRegistry.keys().map(s => [s, s]),
+ description: t('The color scheme for rendering chart'),
+ schemes: () => categoricalSchemeRegistry.getMap(),
+ },
+
+ label_colors: {
+ type: 'ColorMapControl',
+ label: t('Color Map'),
+ default: {},
+ renderTrigger: true,
+ mapStateToProps: (state: State) => ({
+ colorNamespace: state.form_data.color_namespace,
+ colorScheme: state.form_data.color_scheme,
+ }),
+ },
+};
+export default controls;
diff --git a/packages/superset-ui-control-utils/test/ColumnOption.test.tsx b/packages/superset-ui-control-utils/test/ColumnOption.test.tsx
new file mode 100644
index 0000000000..9467d360b1
--- /dev/null
+++ b/packages/superset-ui-control-utils/test/ColumnOption.test.tsx
@@ -0,0 +1,103 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+import { shallow, ShallowWrapper } from 'enzyme';
+
+import { ColumnOption, Props as ColumnOptionProps } from '../src/ColumnOption';
+import { ColumnTypeLabel } from '../src/ColumnTypeLabel';
+import InfoTooltipWithTrigger from '../src/InfoTooltipWithTrigger';
+
+describe('ColumnOption', () => {
+ const defaultProps = {
+ column: {
+ column_name: 'foo',
+ verbose_name: 'Foo',
+ expression: 'SUM(foo)',
+ description: 'Foo is the greatest column of all',
+ },
+ showType: false,
+ };
+
+ let wrapper: ShallowWrapper;
+ let props: ColumnOptionProps;
+ const factory = (o: ColumnOptionProps) => ;
+ beforeEach(() => {
+ wrapper = shallow(factory(defaultProps));
+ props = { ...defaultProps };
+ });
+ it('is a valid element', () => {
+ expect(React.isValidElement()).toBe(true);
+ });
+ it('shows a label with verbose_name', () => {
+ const lbl = wrapper.find('.option-label');
+ expect(lbl).toHaveLength(1);
+ expect(lbl.first().text()).toBe('Foo');
+ });
+ it('shows 2 InfoTooltipWithTrigger', () => {
+ expect(wrapper.find(InfoTooltipWithTrigger)).toHaveLength(2);
+ });
+ it('shows only 1 InfoTooltipWithTrigger when no descr', () => {
+ delete props.column.description;
+ wrapper = shallow(factory(props));
+ expect(wrapper.find(InfoTooltipWithTrigger)).toHaveLength(1);
+ });
+ it('shows a label with column_name when no verbose_name', () => {
+ delete props.column.verbose_name;
+ wrapper = shallow(factory(props));
+ expect(wrapper.find('.option-label').first().text()).toBe('foo');
+ });
+ it('shows a column type label when showType is true', () => {
+ wrapper = shallow(
+ factory({
+ ...props,
+ showType: true,
+ column: {
+ column_name: 'foo',
+ type: 'str',
+ },
+ }),
+ );
+ expect(wrapper.find(ColumnTypeLabel)).toHaveLength(1);
+ });
+ it('column with expression has correct column label if showType is true', () => {
+ props.showType = true;
+ wrapper = shallow(factory(props));
+ expect(wrapper.find(ColumnTypeLabel)).toHaveLength(1);
+ expect(wrapper.find(ColumnTypeLabel).props().type).toBe('expression');
+ });
+ it('shows no column type label when type is null', () => {
+ wrapper = shallow(
+ factory({
+ ...props,
+ showType: true,
+ column: {
+ column_name: 'foo',
+ },
+ }),
+ );
+ expect(wrapper.find(ColumnTypeLabel)).toHaveLength(0);
+ });
+ it('dttm column has correct column label if showType is true', () => {
+ props.showType = true;
+ props.column.is_dttm = true;
+ wrapper = shallow(factory(props));
+ expect(wrapper.find(ColumnTypeLabel)).toHaveLength(1);
+ expect(wrapper.find(ColumnTypeLabel).props().type).toBe('time');
+ });
+});
diff --git a/packages/superset-ui-control-utils/test/ColumnTypeLabel.test.tsx b/packages/superset-ui-control-utils/test/ColumnTypeLabel.test.tsx
new file mode 100644
index 0000000000..e06325e313
--- /dev/null
+++ b/packages/superset-ui-control-utils/test/ColumnTypeLabel.test.tsx
@@ -0,0 +1,68 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { ColumnTypeLabel, Props } from '../src/ColumnTypeLabel';
+
+describe('ColumnOption', () => {
+ const defaultProps = {
+ type: 'string',
+ };
+
+ const props = { ...defaultProps };
+
+ function getWrapper(overrides: Partial) {
+ const wrapper = shallow();
+ return wrapper;
+ }
+
+ it('is a valid element', () => {
+ expect(React.isValidElement()).toBe(true);
+ });
+ it('string type shows ABC icon', () => {
+ const lbl = getWrapper({}).find('.type-label');
+ expect(lbl).toHaveLength(1);
+ expect(lbl.first().text()).toBe('ABC');
+ });
+ it('int type shows # icon', () => {
+ const lbl = getWrapper({ type: 'int(164)' }).find('.type-label');
+ expect(lbl).toHaveLength(1);
+ expect(lbl.first().text()).toBe('#');
+ });
+ it('bool type shows T/F icon', () => {
+ const lbl = getWrapper({ type: 'BOOL' }).find('.type-label');
+ expect(lbl).toHaveLength(1);
+ expect(lbl.first().text()).toBe('T/F');
+ });
+ it('expression type shows function icon', () => {
+ const lbl = getWrapper({ type: 'expression' }).find('.type-label');
+ expect(lbl).toHaveLength(1);
+ expect(lbl.first().text()).toBe('ƒ');
+ });
+ it('unknown type shows question mark', () => {
+ const lbl = getWrapper({ type: 'unknown' }).find('.type-label');
+ expect(lbl).toHaveLength(1);
+ expect(lbl.first().text()).toBe('?');
+ });
+ it('datetime type displays', () => {
+ const lbl = getWrapper({ type: 'datetime' }).find('.fa-clock-o');
+ expect(lbl).toHaveLength(1);
+ });
+});
diff --git a/packages/superset-ui-control-utils/test/mainMetric.test.ts b/packages/superset-ui-control-utils/test/mainMetric.test.ts
new file mode 100644
index 0000000000..0e79957efe
--- /dev/null
+++ b/packages/superset-ui-control-utils/test/mainMetric.test.ts
@@ -0,0 +1,39 @@
+/**
+ * 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 { mainMetric } from '../src/mainMetric';
+
+describe('mainMetric', () => {
+ it('is null when no options', () => {
+ expect(mainMetric([])).toBeUndefined();
+ expect(mainMetric(null)).toBeUndefined();
+ });
+ it('prefers the "count" metric when first', () => {
+ const metrics = [{ metric_name: 'count' }, { metric_name: 'foo' }];
+ expect(mainMetric(metrics)).toBe('count');
+ });
+ it('prefers the "count" metric when not first', () => {
+ const metrics = [{ metric_name: 'foo' }, { metric_name: 'count' }];
+ expect(mainMetric(metrics)).toBe('count');
+ });
+ it('selects the first metric when "count" is not an option', () => {
+ const metrics = [{ metric_name: 'foo' }, { metric_name: 'not_count' }];
+ expect(mainMetric(metrics)).toBe('foo');
+ });
+});