diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index 53e3a198b..7926ffd75 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -25,6 +25,8 @@ import type { JsonValue, Metric, QueryFormData, + QueryFormMetric, + QueryFormColumn, } from '@superset-ui/core'; import { sharedControls } from './shared-controls'; import sharedControlComponents from './shared-controls/components'; @@ -340,11 +342,26 @@ export interface ControlPanelSectionConfig { controlSetRows: ControlSetRow[]; } +export interface StandardizedState { + metrics: QueryFormMetric[]; + columns: QueryFormColumn[]; +} + +export interface StandardizedFormDataInterface { + standardizedState: StandardizedState; + memorizedFormData: Map; +} + export interface ControlPanelConfig { controlPanelSections: (ControlPanelSectionConfig | null)[]; controlOverrides?: ControlOverrides; sectionOverrides?: SectionOverrides; onInit?: (state: ControlStateMapping) => void; + denormalizeFormData?: ( + formData: QueryFormData & { + standardizedFormData: StandardizedFormDataInterface; + }, + ) => QueryFormData; } export type ControlOverrides = { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts index 8511c3ca5..c715c8f23 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts @@ -96,4 +96,8 @@ export default { label: t('Number format'), }, }, + denormalizeFormData: formData => ({ + ...formData, + metric: formData.standardizedFormData.standardizedState.metrics[0], + }), } as ControlPanelConfig; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx index 3ba00f55e..c61ec6d04 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx @@ -270,6 +270,10 @@ const config: ControlPanelConfig = { label: t('Number format'), }, }, + denormalizeFormData: formData => ({ + ...formData, + metric: formData.standardizedFormData.standardizedState.metrics[0], + }), }; export default config; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx index 9056446f9..6e3a1ba59 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx @@ -253,6 +253,11 @@ const config: ControlPanelConfig = { default: 100, }, }, + denormalizeFormData: formData => ({ + ...formData, + metric: formData.standardizedFormData.standardizedState.metrics[0], + groupby: formData.standardizedFormData.standardizedState.columns, + }), }; export default config; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx index f234df0c8..eaf3cb261 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/controlPanel.tsx @@ -240,6 +240,10 @@ const config: ControlPanelConfig = { default: rowLimit, }, }, + denormalizeFormData: formData => ({ + ...formData, + metrics: formData.standardizedFormData.standardizedState.metrics, + }), }; export default config; diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx index 99efb8bbc..acbdb04bc 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; import { + ensureIsArray, QueryFormMetric, smartDateFormatter, t, @@ -367,6 +368,17 @@ const config: ControlPanelConfig = { ], }, ], + denormalizeFormData: formData => { + const groupbyColumns = + formData.standardizedFormData.standardizedState.columns.filter( + col => !ensureIsArray(formData.groupbyRows).includes(col), + ); + return { + ...formData, + metrics: formData.standardizedFormData.standardizedState.metrics, + groupbyColumns, + }; + }, }; export default config; diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index bb855bd7c..4f5530f6b 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -531,6 +531,11 @@ const config: ControlPanelConfig = { ], }, ], + denormalizeFormData: formData => ({ + ...formData, + metrics: formData.standardizedFormData.standardizedState.metrics, + groupby: formData.standardizedFormData.standardizedState.columns, + }), }; export default config; diff --git a/superset-frontend/src/explore/controlUtils/index.ts b/superset-frontend/src/explore/controlUtils/index.ts index d9cb1132f..b39233422 100644 --- a/superset-frontend/src/explore/controlUtils/index.ts +++ b/superset-frontend/src/explore/controlUtils/index.ts @@ -21,3 +21,4 @@ export * from './getControlConfig'; export * from './getControlState'; export * from './getFormDataFromControls'; export * from './getControlValuesCompatibleWithDatasource'; +export * from './standardizedFormData'; diff --git a/superset-frontend/src/explore/controlUtils/standardizedFormData.test.tsx b/superset-frontend/src/explore/controlUtils/standardizedFormData.test.tsx new file mode 100644 index 000000000..e048c0449 --- /dev/null +++ b/superset-frontend/src/explore/controlUtils/standardizedFormData.test.tsx @@ -0,0 +1,307 @@ +/** + * 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 { getChartControlPanelRegistry, QueryFormData } from '@superset-ui/core'; +import TableChartPlugin from '@superset-ui/plugin-chart-table'; +import { BigNumberTotalChartPlugin } from '@superset-ui/plugin-chart-echarts'; +import { sections } from '@superset-ui/chart-controls'; +import { + StandardizedFormData, + sharedControls, + publicControls, +} from './standardizedFormData'; +import { xAxisControl } from '../../../plugins/plugin-chart-echarts/src/controls'; + +describe('should collect control values and create SFD', () => { + const sharedControlsFormData = {}; + Object.entries(sharedControls).forEach(([, names]) => { + names.forEach(name => { + sharedControlsFormData[name] = name; + }); + }); + const publicControlsFormData = Object.fromEntries( + publicControls.map((name, idx) => [[name], idx]), + ); + const sourceMockFormData: QueryFormData = { + ...sharedControlsFormData, + ...publicControlsFormData, + datasource: '100__table', + viz_type: 'source_viz', + }; + const sourceMockStore = { + form_data: sourceMockFormData, + controls: Object.fromEntries( + Object.entries(sourceMockFormData).map(([key, value]) => [ + key, + { value }, + ]), + ), + datasource: { + type: 'table', + columns: [], + }, + }; + beforeAll(() => { + getChartControlPanelRegistry().registerValue('source_viz', { + controlPanelSections: [ + sections.advancedAnalyticsControls, + { + label: 'transform controls', + controlSetRows: publicControls.map(control => [control]), + }, + { + label: 'axis column', + controlSetRows: [[xAxisControl]], + }, + ], + }); + getChartControlPanelRegistry().registerValue('target_viz', { + controlPanelSections: [ + sections.advancedAnalyticsControls, + { + label: 'transform controls', + controlSetRows: publicControls.map(control => [control]), + }, + { + label: 'axis column', + controlSetRows: [[xAxisControl]], + }, + ], + denormalizeFormData: (formData: QueryFormData) => ({ + ...formData, + columns: formData.standardizedFormData.standardizedState.columns, + metrics: formData.standardizedFormData.standardizedState.metrics, + }), + }); + }); + + test('collect sharedControls', () => { + const sfd = new StandardizedFormData(sourceMockFormData); + + expect(sfd.dumpSFD().standardizedState.metrics).toEqual( + sharedControls.metrics.map(controlName => controlName), + ); + expect(sfd.dumpSFD().standardizedState.columns).toEqual( + sharedControls.columns.map(controlName => controlName), + ); + }); + + test('should transform all publicControls', () => { + const sfd = new StandardizedFormData(sourceMockFormData); + const { formData } = sfd.transform('target_viz', sourceMockStore); + Object.entries(publicControlsFormData).forEach(([key]) => { + expect(formData).toHaveProperty(key); + }); + Object.entries(sharedControls).forEach(([key, value]) => { + expect(formData[key]).toEqual(value); + }); + }); + + test('should inherit standardizedFormData and memorizedFormData is LIFO', () => { + // from source_viz to target_viz + const sfd = new StandardizedFormData(sourceMockFormData); + const { formData, controlsState } = sfd.transform( + 'target_viz', + sourceMockStore, + ); + expect( + formData.standardizedFormData.memorizedFormData.map( + (fd: [string, QueryFormData]) => fd[0], + ), + ).toEqual(['source_viz']); + + // from target_viz to source_viz + const sfd2 = new StandardizedFormData(formData); + const { formData: fd2, controlsState: cs2 } = sfd2.transform('source_viz', { + ...sourceMockStore, + form_data: formData, + controls: controlsState, + }); + expect( + fd2.standardizedFormData.memorizedFormData.map( + (fd: [string, QueryFormData]) => fd[0], + ), + ).toEqual(['source_viz', 'target_viz']); + + // from source_viz to target_viz + const sfd3 = new StandardizedFormData(fd2); + const { formData: fd3 } = sfd3.transform('target_viz', { + ...sourceMockStore, + form_data: fd2, + controls: cs2, + }); + expect( + fd3.standardizedFormData.memorizedFormData.map( + (fd: [string, QueryFormData]) => fd[0], + ), + ).toEqual(['target_viz', 'source_viz']); + }); +}); + +describe('should transform form_data between table and bigNumberTotal', () => { + const tableVizFormData = { + datasource: '30__table', + viz_type: 'table', + time_grain_sqla: 'P1D', + time_range: 'No filter', + query_mode: 'aggregate', + groupby: ['name'], + metrics: ['count'], + all_columns: [], + percent_metrics: [], + adhoc_filters: [], + order_by_cols: [], + row_limit: 10000, + server_page_length: 10, + order_desc: true, + table_timestamp_format: 'smart_date', + show_cell_bars: true, + color_pn: true, + applied_time_extras: {}, + url_params: { + form_data_key: + 'p3No_sqDW7k-kMTzlBPAPd9vwp1IXTf6stbyzjlrPPa0ninvdYUUiMC6F1iKit3Y', + dataset_id: '30', + }, + }; + const tableVizStore = { + form_data: tableVizFormData, + controls: { + datasource: { + value: '30__table', + }, + viz_type: { + value: 'table', + }, + slice_id: {}, + cache_timeout: {}, + url_params: { + value: { + form_data_key: + 'p3No_sqDW7k-kMTzlBPAPd9vwp1IXTf6stbyzjlrPPa0ninvdYUUiMC6F1iKit3Y', + dataset_id: '30', + }, + }, + granularity_sqla: {}, + time_grain_sqla: { + value: 'P1D', + }, + time_range: { + value: 'No filter', + }, + query_mode: { + value: 'aggregate', + }, + groupby: { + value: ['name'], + }, + metrics: { + value: ['count'], + }, + all_columns: { + value: [], + }, + percent_metrics: { + value: [], + }, + adhoc_filters: { + value: [], + }, + timeseries_limit_metric: {}, + order_by_cols: { + value: [], + }, + server_pagination: {}, + row_limit: { + value: 10000, + }, + server_page_length: { + value: 10, + }, + include_time: {}, + order_desc: { + value: true, + }, + show_totals: {}, + emit_filter: {}, + table_timestamp_format: { + value: 'smart_date', + }, + page_length: {}, + include_search: {}, + show_cell_bars: { + value: true, + }, + align_pn: {}, + color_pn: { + value: true, + }, + column_config: {}, + conditional_formatting: {}, + }, + datasource: { + type: 'table', + columns: [], + }, + }; + + beforeAll(() => { + getChartControlPanelRegistry().registerValue( + 'big_number_total', + new BigNumberTotalChartPlugin().controlPanel, + ); + getChartControlPanelRegistry().registerValue( + 'table', + new TableChartPlugin().controlPanel, + ); + }); + + test('transform', () => { + // table -> bigNumberTotal + const sfd = new StandardizedFormData(tableVizFormData); + const { formData: bntFormData, controlsState: bntControlsState } = + sfd.transform('big_number_total', tableVizStore); + expect(Object.keys(bntFormData).sort()).toEqual( + [...Object.keys(bntControlsState), 'standardizedFormData'].sort(), + ); + expect(bntFormData.viz_type).toBe('big_number_total'); + expect(bntFormData.metric).toBe('count'); + + // change control values + bntFormData.metric = 'sum(sales)'; + bntFormData.time_range = '2021 : 2022'; + bntControlsState.metric.value = 'sum(sales)'; + bntControlsState.time_range.value = '2021 : 2022'; + + // bigNumberTotal -> table + const sfd2 = new StandardizedFormData(bntFormData); + const { formData: tblFormData, controlsState: tblControlsState } = + sfd2.transform('table', { + ...tableVizStore, + form_data: bntFormData, + controls: bntControlsState, + }); + expect(Object.keys(tblFormData).sort()).toEqual( + [...Object.keys(tblControlsState), 'standardizedFormData'].sort(), + ); + expect(tblFormData.viz_type).toBe('table'); + expect(tblFormData.metrics).toEqual(['sum(sales)']); + expect(tblFormData.groupby).toEqual([]); + expect(tblFormData.time_range).toBe('2021 : 2022'); + }); +}); diff --git a/superset-frontend/src/explore/controlUtils/standardizedFormData.ts b/superset-frontend/src/explore/controlUtils/standardizedFormData.ts new file mode 100644 index 000000000..c42ef9508 --- /dev/null +++ b/superset-frontend/src/explore/controlUtils/standardizedFormData.ts @@ -0,0 +1,182 @@ +/** + * 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 { + ensureIsArray, + getChartControlPanelRegistry, + QueryFormData, +} from '@superset-ui/core'; +import { + ControlStateMapping, + StandardizedState, + StandardizedFormDataInterface, +} from '@superset-ui/chart-controls'; +import { getControlsState } from 'src/explore/store'; +import { getFormDataFromControls } from './getFormDataFromControls'; + +export const sharedControls: Record = { + metrics: ['metric', 'metrics', 'metric_2'], + columns: ['groupby', 'columns', 'groupbyColumns', 'groupbyRows'], +}; +export const publicControls = [ + // time section + 'granularity_sqla', + 'time_grain_sqla', + 'time_range', + // filters + 'adhoc_filters', + // subquery limit(series limit) + 'limit', + // order by clause + 'timeseries_limit_metric', + 'series_limit_metric', + // desc or asc in order by clause + 'order_desc', + // outer query limit + 'row_limit', + // x asxs column + 'x_axis', + // advanced analytics - rolling window + 'rolling_type', + 'rolling_periods', + 'min_periods', + // advanced analytics - time comparison + 'time_compare', + 'comparison_type', + // advanced analytics - resample + 'resample_rule', + 'resample_method', +]; + +export class StandardizedFormData { + private sfd: StandardizedFormDataInterface; + + constructor(sourceFormData: QueryFormData) { + /* + * Support form_data for smooth switching between different viz + * */ + const standardizedState = { + metrics: [], + columns: [], + }; + const formData = Object.freeze(sourceFormData); + const reversedMap = StandardizedFormData.getReversedMap(); + + Object.entries(formData).forEach(([key, value]) => { + if (reversedMap.has(key)) { + standardizedState[reversedMap.get(key)].push(...ensureIsArray(value)); + } + }); + + const memorizedFormData = Array.isArray( + formData?.standardizedFormData?.memorizedFormData, + ) + ? new Map(formData.standardizedFormData.memorizedFormData) + : new Map(); + const vizType = formData.viz_type; + if (memorizedFormData.has(vizType)) { + memorizedFormData.delete(vizType); + } + memorizedFormData.set(vizType, formData); + this.sfd = { + standardizedState, + memorizedFormData, + }; + } + + static getReversedMap() { + const reversedMap = new Map(); + Object.entries(sharedControls).forEach(([key, names]) => { + names.forEach(name => { + reversedMap.set(name, key); + }); + }); + return reversedMap; + } + + private getLatestFormData(vizType: string): QueryFormData { + if (this.sfd.memorizedFormData.has(vizType)) { + return this.sfd.memorizedFormData.get(vizType) as QueryFormData; + } + + return this.memorizedFormData.slice(-1)[0][1]; + } + + private get standardizedState() { + return this.sfd.standardizedState; + } + + private get memorizedFormData() { + return Array.from(this.sfd.memorizedFormData.entries()); + } + + dumpSFD() { + return { + standardizedState: this.standardizedState, + memorizedFormData: this.memorizedFormData, + }; + } + + transform( + targetVizType: string, + exploreState: Record, + ): { + formData: QueryFormData; + controlsState: ControlStateMapping; + } { + /* + * Transfrom form_data between different viz. Return new form_data and controlsState. + * 1. get memorized form_data by viz type or get previous form_data + * 2. collect public control values + * 3. generate initial targetControlsState + * 4. attach `standardizedFormData` to the initial form_data + * 5. call denormalizeFormData to transform initial form_data if the plugin was defined + * 6. use final form_data to generate controlsState + * */ + const latestFormData = this.getLatestFormData(targetVizType); + const publicFormData = {}; + publicControls.forEach(key => { + if (key in exploreState.form_data) { + publicFormData[key] = exploreState.form_data[key]; + } + }); + const targetControlsState = getControlsState(exploreState, { + ...latestFormData, + ...publicFormData, + viz_type: targetVizType, + }); + const targetFormData = { + ...getFormDataFromControls(targetControlsState), + standardizedFormData: this.dumpSFD(), + }; + + const controlPanel = getChartControlPanelRegistry().get(targetVizType); + if (controlPanel?.denormalizeFormData) { + const transformed = controlPanel.denormalizeFormData(targetFormData); + return { + formData: transformed, + controlsState: getControlsState(exploreState, transformed), + }; + } + + return { + formData: targetFormData, + controlsState: targetControlsState, + }; + } +} diff --git a/superset-frontend/src/explore/reducers/exploreReducer.js b/superset-frontend/src/explore/reducers/exploreReducer.js index 2897832ff..818bb31c8 100644 --- a/superset-frontend/src/explore/reducers/exploreReducer.js +++ b/superset-frontend/src/explore/reducers/exploreReducer.js @@ -23,9 +23,9 @@ import { DEFAULT_TIME_RANGE } from 'src/explore/constants'; import { getControlsState } from 'src/explore/store'; import { getControlConfig, - getFormDataFromControls, getControlStateFromControlConfig, getControlValuesCompatibleWithDatasource, + StandardizedFormData, } from 'src/explore/controlUtils'; import * as actions from 'src/explore/actions/exploreActions'; @@ -129,11 +129,10 @@ export default function exploreReducer(state = {}, action) { }; }, [actions.SET_FIELD_VALUE]() { - const new_form_data = state.form_data; + const { controlName, value, validationErrors } = action; + let new_form_data = { ...state.form_data, [controlName]: value }; const old_metrics_data = state.form_data.metrics; const new_column_config = state.form_data.column_config; - const { controlName, value, validationErrors } = action; - new_form_data[controlName] = value; const vizType = new_form_data.viz_type; @@ -204,18 +203,17 @@ export default function exploreReducer(state = {}, action) { }); const hasErrors = errors && errors.length > 0; - const currentControlsState = + const isVizSwitch = action.controlName === 'viz_type' && - action.value !== state.controls.viz_type.value - ? // rebuild the full control state if switching viz type - getControlsState( - state, - getFormDataFromControls({ - ...state.controls, - viz_type: control, - }), - ) - : state.controls; + action.value !== state.controls.viz_type.value; + let currentControlsState = state.controls; + if (isVizSwitch) { + // get StandardizedFormData from source form_data + const sfd = new StandardizedFormData(state.form_data); + const transformed = sfd.transform(action.value, state); + new_form_data = transformed.formData; + currentControlsState = transformed.controlsState; + } return { ...state,