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'); + }); +});