/* eslint-disable camelcase */ /** * 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 { ColumnMeta, ColumnOption, ControlConfig, ControlPanelConfig, ControlPanelsContainerProps, ControlPanelState, ControlState, ControlStateMapping, D3_TIME_FORMAT_OPTIONS, Dataset, DEFAULT_MAX_ROW, DEFAULT_MAX_ROW_TABLE_SERVER, defineSavedMetrics, formatSelectOptions, getStandardizedControls, QueryModeLabel, sections, sharedControls, shouldSkipMetricColumn, isRegularMetric, isPercentMetric, } from '@superset-ui/chart-controls'; import { ensureIsArray, FeatureFlag, isAdhocColumn, isFeatureEnabled, isPhysicalColumn, validateInteger, QueryFormColumn, QueryMode, SMART_DATE_ID, t, validateMaxValue, validateServerPagination, } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/api/core'; import { isEmpty, last } from 'lodash'; import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts'; import { ColorSchemeEnum } from './types'; /** * Generate comparison column names for a given column. */ const generateComparisonColumns = (colname: string) => [ `${t('Main')} ${colname}`, `# ${colname}`, `△ ${colname}`, `% ${colname}`, ]; /** * Generate column types for the comparison columns. */ const generateComparisonColumnTypes = (count: number) => Array(count).fill(GenericDataType.Numeric); function getQueryMode(controls: ControlStateMapping): QueryMode { const mode = controls?.query_mode?.value; if (mode === QueryMode.Aggregate || mode === QueryMode.Raw) { return mode as QueryMode; } const rawColumns = controls?.all_columns?.value as | QueryFormColumn[] | undefined; const hasRawColumns = rawColumns && rawColumns.length > 0; return hasRawColumns ? QueryMode.Raw : QueryMode.Aggregate; } const processComparisonColumns = (columns: any[], suffix: string) => columns .map(col => { if (!col.label.includes(suffix)) { return [ { label: `${t('Main')} ${col.label}`, value: `${t('Main')} ${col.value}`, }, { label: `# ${col.label}`, value: `# ${col.value}`, }, { label: `△ ${col.label}`, value: `△ ${col.value}`, }, { label: `% ${col.label}`, value: `% ${col.value}`, }, ]; } return []; }) .flat(); /** * Visibility check */ function isQueryMode(mode: QueryMode) { return ({ controls }: Pick) => getQueryMode(controls) === mode; } const isAggMode = isQueryMode(QueryMode.Aggregate); const isRawMode = isQueryMode(QueryMode.Raw); const validateAggControlValues = ( controls: ControlStateMapping, values: any[], ) => { const areControlsEmpty = values.every(val => ensureIsArray(val).length === 0); return areControlsEmpty && isAggMode({ controls }) ? [t('Group By, Metrics or Percentage Metrics must have a value')] : []; }; const queryMode: ControlConfig<'RadioButtonControl'> = { type: 'RadioButtonControl', label: t('Query mode'), default: null, options: [ [QueryMode.Aggregate, QueryModeLabel[QueryMode.Aggregate]], [QueryMode.Raw, QueryModeLabel[QueryMode.Raw]], ], mapStateToProps: ({ controls }) => ({ value: getQueryMode(controls) }), rerender: ['all_columns', 'groupby', 'metrics', 'percent_metrics'], }; const allColumnsControl: typeof sharedControls.groupby = { ...sharedControls.groupby, label: t('Columns'), description: t('Columns to display'), multi: true, freeForm: true, allowAll: true, commaChoosesOption: false, optionRenderer: c => , valueRenderer: c => , valueKey: 'column_name', mapStateToProps: ({ datasource, controls }, controlState) => ({ options: datasource?.columns || [], queryMode: getQueryMode(controls), externalValidationErrors: isRawMode({ controls }) && ensureIsArray(controlState?.value).length === 0 ? [t('must have a value')] : [], }), visibility: isRawMode, resetOnHide: false, }; const percentMetricsControl: typeof sharedControls.metrics = { ...sharedControls.metrics, label: t('Percentage metrics'), description: t( 'Select one or many metrics to display, that will be displayed in the percentages of total. ' + 'Percentage metrics will be calculated only from data within the row limit. ' + 'You can use an aggregation function on a column or write custom SQL to create a percentage metric.', ), visibility: isAggMode, resetOnHide: false, mapStateToProps: ({ datasource, controls }, controlState) => ({ columns: datasource?.columns || [], savedMetrics: defineSavedMetrics(datasource), datasource, datasourceType: datasource?.type, queryMode: getQueryMode(controls), externalValidationErrors: validateAggControlValues(controls, [ controls.groupby?.value, controls.metrics?.value, controlState?.value, ]), }), rerender: ['groupby', 'metrics'], default: [], validators: [], }; /* Options for row limit control */ export const ROW_LIMIT_OPTIONS_TABLE = [ 10, 50, 100, 250, 500, 1000, 5000, 10000, 50000, 100000, 150000, 200000, 250000, 300000, 350000, 400000, 450000, 500000, ]; const config: ControlPanelConfig = { controlPanelSections: [ { label: t('Query'), expanded: true, controlSetRows: [ [ { name: 'query_mode', config: queryMode, }, ], [ { name: 'groupby', override: { visibility: isAggMode, resetOnHide: false, mapStateToProps: ( state: ControlPanelState, controlState: ControlState, ) => { const { controls } = state; const originalMapStateToProps = sharedControls?.groupby?.mapStateToProps; const newState = originalMapStateToProps?.(state, controlState) ?? {}; newState.externalValidationErrors = validateAggControlValues( controls, [controls.metrics?.value, controlState.value], ); return newState; }, rerender: ['metrics', 'percent_metrics'], }, }, ], [ { name: 'time_grain_sqla', config: { ...sharedControls.time_grain_sqla, visibility: ({ controls }) => { const dttmLookup = Object.fromEntries( ensureIsArray(controls?.groupby?.options).map(option => [ option.column_name, option.is_dttm, ]), ); return ensureIsArray(controls?.groupby.value) .map(selection => { if (isAdhocColumn(selection)) { return true; } if (isPhysicalColumn(selection)) { return !!dttmLookup[selection]; } return false; }) .some(Boolean); }, }, }, 'temporal_columns_lookup', ], [ { name: 'metrics', override: { validators: [], visibility: isAggMode, resetOnHide: false, mapStateToProps: ( { controls, datasource, form_data }: ControlPanelState, controlState: ControlState, ) => ({ columns: datasource?.columns[0]?.hasOwnProperty('filterable') ? (datasource as Dataset)?.columns?.filter( (c: ColumnMeta) => c.filterable, ) : datasource?.columns, savedMetrics: defineSavedMetrics(datasource), // current active adhoc metrics selectedMetrics: form_data.metrics || (form_data.metric ? [form_data.metric] : []), datasource, externalValidationErrors: validateAggControlValues(controls, [ controls.groupby?.value, controlState.value, ]), }), rerender: ['groupby'], }, }, { name: 'all_columns', config: allColumnsControl, }, ], [ { name: 'percent_metrics', config: percentMetricsControl, }, ], ['adhoc_filters'], [ { name: 'timeseries_limit_metric', override: { visibility: isAggMode, resetOnHide: false, }, }, { name: 'order_by_cols', config: { type: 'SelectControl', label: t('Ordering'), description: t('Order results by selected columns'), multi: true, default: [], mapStateToProps: ({ datasource }) => ({ choices: datasource?.hasOwnProperty('order_by_choices') ? (datasource as Dataset)?.order_by_choices : datasource?.columns || [], }), visibility: isRawMode, resetOnHide: false, }, }, ], [ { name: 'order_desc', config: { type: 'CheckboxControl', label: t('Sort descending'), default: true, description: t( 'If enabled, this control sorts the results/values descending, otherwise it sorts the results ascending.', ), visibility: ({ controls }: ControlPanelsContainerProps) => { const hasSortMetric = Boolean( controls?.timeseries_limit_metric?.value, ); return hasSortMetric && isAggMode({ controls }); }, resetOnHide: false, }, }, ], [ { name: 'server_pagination', config: { type: 'CheckboxControl', label: t('Server pagination'), description: t( 'Enable server side pagination of results (experimental feature)', ), default: false, }, }, ], [ { name: 'server_page_length', config: { type: 'SelectControl', freeForm: true, label: t('Server Page Length'), default: 10, choices: SERVER_PAGE_SIZE_OPTIONS, description: t('Rows per page, 0 means no pagination'), visibility: ({ controls }: ControlPanelsContainerProps) => Boolean(controls?.server_pagination?.value), validators: [validateInteger], }, }, ], [ { name: 'row_limit', config: { type: 'SelectControl', freeForm: true, label: t('Row limit'), clearable: false, mapStateToProps: state => ({ maxValue: state?.common?.conf?.TABLE_VIZ_MAX_ROW_SERVER, server_pagination: state?.form_data?.server_pagination, maxValueWithoutServerPagination: state?.common?.conf?.SQL_MAX_ROW, }), validators: [ validateInteger, (v, state) => validateMaxValue( v, state?.maxValue || DEFAULT_MAX_ROW_TABLE_SERVER, ), (v, state) => validateServerPagination( v, state?.server_pagination, state?.maxValueWithoutServerPagination || DEFAULT_MAX_ROW, state?.maxValue || DEFAULT_MAX_ROW_TABLE_SERVER, ), ], // Re run the validations when this control value validationDependancies: ['server_pagination'], default: 10000, choices: formatSelectOptions(ROW_LIMIT_OPTIONS_TABLE), description: t( 'Limits the number of the rows that are computed in the query that is the source of the data used for this chart.', ), }, override: { default: 1000, }, }, ], [ { name: 'show_totals', config: { type: 'CheckboxControl', label: t('Show summary'), default: false, description: t( 'Show total aggregations of selected metrics. Note that row limit does not apply to the result.', ), visibility: isAggMode, resetOnHide: false, }, }, ], ], }, { label: t('Options'), expanded: true, controlSetRows: [ [ { name: 'table_timestamp_format', config: { type: 'SelectControl', freeForm: true, label: t('Timestamp format'), default: SMART_DATE_ID, renderTrigger: true, clearable: false, choices: D3_TIME_FORMAT_OPTIONS, description: t('D3 time format for datetime columns'), }, }, ], [ { name: 'page_length', config: { type: 'SelectControl', freeForm: true, renderTrigger: true, label: t('Page length'), default: null, choices: PAGE_SIZE_OPTIONS, description: t('Rows per page, 0 means no pagination'), visibility: ({ controls }: ControlPanelsContainerProps) => !controls?.server_pagination?.value, }, }, null, ], [ { name: 'include_search', config: { type: 'CheckboxControl', label: t('Search box'), renderTrigger: true, default: false, description: t('Whether to include a client-side search box'), }, }, ], [ { name: 'column_config', config: { type: 'ColumnConfigControl', label: t('Customize columns'), description: t('Further customize how to display each column'), width: 400, height: 320, renderTrigger: true, shouldMapStateToProps() { return true; }, mapStateToProps(explore, _, chart) { const timeComparisonValue = explore?.controls?.time_compare?.value; const { colnames: _colnames, coltypes: _coltypes } = chart?.queriesResponse?.[0] ?? {}; let colnames: string[] = _colnames || []; let coltypes: GenericDataType[] = _coltypes || []; const childColumnMap: Record = {}; const timeComparisonColumnMap: Record = {}; if (!isEmpty(timeComparisonValue)) { /** * Replace numeric columns with sets of comparison columns. */ const updatedColnames: string[] = []; const updatedColtypes: GenericDataType[] = []; colnames .filter( colname => last(colname.split('__')) !== timeComparisonValue, ) .forEach((colname, index) => { if ( shouldSkipMetricColumn({ colname, colnames, formData: explore.form_data, }) ) { return; } const isMetric = isRegularMetric( colname, explore.form_data, ); const isPercentMetricValue = isPercentMetric( colname, explore.form_data, ); if (isMetric || isPercentMetricValue) { const comparisonColumns = generateComparisonColumns(colname); comparisonColumns.forEach((name, idx) => { updatedColnames.push(name); updatedColtypes.push( ...generateComparisonColumnTypes(4), ); timeComparisonColumnMap[name] = true; if (idx === 0 && name.startsWith('Main ')) { childColumnMap[name] = false; } else { childColumnMap[name] = true; } }); } else { updatedColnames.push(colname); updatedColtypes.push(coltypes[index]); childColumnMap[colname] = false; timeComparisonColumnMap[colname] = false; } }); colnames = updatedColnames; coltypes = updatedColtypes; } return { columnsPropsObject: { colnames, coltypes, childColumnMap, timeComparisonColumnMap, }, }; }, }, }, ], ], }, { label: t('Visual formatting'), expanded: true, controlSetRows: [ [ { name: 'show_cell_bars', config: { type: 'CheckboxControl', label: t('Show cell bars'), renderTrigger: true, default: true, description: t( 'Whether to display a bar chart background in table columns', ), }, }, ], [ { name: 'align_pn', config: { type: 'CheckboxControl', label: t('Align +/-'), renderTrigger: true, default: false, description: t( 'Whether to align background charts with both positive and negative values at 0', ), }, }, ], [ { name: 'color_pn', config: { type: 'CheckboxControl', label: t('Add colors to cell bars for +/-'), renderTrigger: true, default: true, description: t( 'Whether to colorize numeric values by whether they are positive or negative', ), }, }, ], [ { name: 'comparison_color_enabled', config: { type: 'CheckboxControl', label: t('Basic conditional formatting'), renderTrigger: true, visibility: ({ controls }) => !isEmpty(controls?.time_compare?.value), default: false, description: t( 'This will be applied to the whole table. Arrows (↑ and ↓) will be added to ' + 'main columns for increase and decrease. Basic conditional formatting can be ' + 'overwritten by conditional formatting below.', ), }, }, ], [ { name: 'comparison_color_scheme', config: { type: 'SelectControl', label: t('color type'), default: ColorSchemeEnum.Green, renderTrigger: true, choices: [ [ColorSchemeEnum.Green, 'Green for increase, red for decrease'], [ColorSchemeEnum.Red, 'Red for increase, green for decrease'], ], visibility: ({ controls }) => !isEmpty(controls?.time_compare?.value) && Boolean(controls?.comparison_color_enabled?.value), description: t( 'Adds color to the chart symbols based on the positive or ' + 'negative change from the comparison value.', ), }, }, ], [ { name: 'conditional_formatting', config: { type: 'ConditionalFormattingControl', renderTrigger: true, label: t('Custom conditional formatting'), extraColorChoices: [ { value: ColorSchemeEnum.Green, label: t('Green for increase, red for decrease'), }, { value: ColorSchemeEnum.Red, label: t('Red for increase, green for decrease'), }, ], description: t( 'Apply conditional color formatting to numeric columns', ), shouldMapStateToProps() { return true; }, mapStateToProps(explore, _, chart) { const verboseMap = explore?.datasource?.hasOwnProperty( 'verbose_map', ) ? (explore?.datasource as Dataset)?.verbose_map : (explore?.datasource?.columns ?? {}); const chartStatus = chart?.chartStatus; const { colnames, coltypes } = chart?.queriesResponse?.[0] ?? {}; const numericColumns = Array.isArray(colnames) && Array.isArray(coltypes) ? colnames .filter( (colname: string, index: number) => coltypes[index] === GenericDataType.Numeric, ) .map((colname: string) => ({ value: colname, label: Array.isArray(verboseMap) ? colname : (verboseMap[colname] ?? colname), dataType: colnames && coltypes[colnames?.indexOf(colname)], })) : []; const columnOptions = explore?.controls?.time_compare?.value ? processComparisonColumns( numericColumns || [], ensureIsArray( explore?.controls?.time_compare?.value, )[0]?.toString() || '', ) : numericColumns; return { removeIrrelevantConditions: chartStatus === 'success', columnOptions, verboseMap, }; }, }, }, ], ], }, { ...sections.timeComparisonControls({ multi: false, showCalculationType: false, showFullChoices: false, }), visibility: ({ controls }) => isAggMode({ controls }) && isFeatureEnabled(FeatureFlag.TableV2TimeComparisonEnabled), }, ], formDataOverrides: formData => ({ ...formData, metrics: getStandardizedControls().popAllMetrics(), groupby: getStandardizedControls().popAllColumns(), }), }; export default config;