This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 3f77b342cb8 fix(explore): persist aliased column config by real key
(#44955)
3f77b342cb8 is described below
commit 3f77b342cb84d2ec0a5563addc73e3091f47cc43
Author: Amogh Atreya <[email protected]>
AuthorDate: Fri Oct 23 00:05:05 2026 -0700
fix(explore): persist aliased column config by real key (#44955)
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
.../src/utils/headerGroups.test.ts | 182 ++++++++++++++++++++-
.../src/utils/headerGroups.ts | 68 +++++++-
.../src/controlPanel.tsx | 5 +
.../src/transformProps.ts | 9 +-
.../src/utils/isEqualColumns.ts | 8 +
.../test/controlPanel.test.tsx | 34 ++++
.../test/transformProps.test.ts | 159 ++++++++++++++++++
.../src/Radar/controlPanel.tsx | 10 +-
.../plugin-chart-table/src/controlPanel.tsx | 5 +
.../plugin-chart-table/src/transformProps.ts | 9 +-
.../plugin-chart-table/src/utils/isEqualColumns.ts | 8 +
.../plugin-chart-table/test/TableChart.test.tsx | 112 +++++++++++++
.../plugin-chart-table/test/controlPanel.test.tsx | 34 ++++
.../ColumnConfigControl.test.tsx | 75 +++++++++
.../ColumnConfigControl/ColumnConfigControl.tsx | 20 ++-
.../controls/ColumnConfigControl/types.ts | 1 +
16 files changed, 722 insertions(+), 17 deletions(-)
diff --git
a/superset-frontend/packages/superset-ui-chart-controls/src/utils/headerGroups.test.ts
b/superset-frontend/packages/superset-ui-chart-controls/src/utils/headerGroups.test.ts
index 91c3f9b7255..e982972d19e 100644
---
a/superset-frontend/packages/superset-ui-chart-controls/src/utils/headerGroups.test.ts
+++
b/superset-frontend/packages/superset-ui-chart-controls/src/utils/headerGroups.test.ts
@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ComparisonType, QueryMode } from '@superset-ui/core';
+import { ComparisonType, DTTM_ALIAS, QueryMode } from '@superset-ui/core';
+import { COLUMN_NAME_ALIASES } from '../constants';
import {
buildHeaderGroupRows,
buildTimeComparisonHeaderGroups,
@@ -29,6 +30,7 @@ import {
isHeaderGroupsTimeComparisonEnabled,
nestColDefsInHeaderGroups,
normalizeColumnConfigKeys,
+ getKnownColumnConfigKeys,
resolveHeaderGroups,
syncTimeComparisonGroups,
toStoredTimeComparisonColumnKey,
@@ -377,6 +379,29 @@ test('toStoredTimeComparisonColumnKey leaves unmatched
comparison-like keys unch
).toBe('# profit');
});
+test('normalizeColumnConfigKeys leaves real result columns alone when a
sibling looks like a comparison slot', () => {
+ expect(
+ normalizeColumnConfigKeys(
+ { 'Previous revenue': { customColumnName: 'Prior' } },
+ ['Main revenue', 'Previous revenue', '# revenue'],
+ ),
+ ).toEqual({
+ 'Previous revenue': { customColumnName: 'Prior' },
+ });
+});
+
+test('normalizeColumnConfigKeys does not treat a known dataset column as a
comparison slot', () => {
+ expect(
+ normalizeColumnConfigKeys(
+ { 'Previous revenue': { visible: false } },
+ ['Main revenue', '# revenue'],
+ ['Previous revenue', 'Main revenue', '# revenue'],
+ ),
+ ).toEqual({
+ 'Previous revenue': { visible: false },
+ });
+});
+
test('normalizeColumnConfigKeys remaps localized Main keys onto stored
colnames', () => {
expect(
normalizeColumnConfigKeys(
@@ -393,6 +418,9 @@ test('normalizeColumnConfigKeys remaps localized Main keys
onto stored colnames'
});
test('normalizeColumnConfigKeys prefers an existing stored Main entry over a
localized duplicate', () => {
+ const expected = {
+ 'Main revenue': { columnWidth: 120 },
+ };
expect(
normalizeColumnConfigKeys(
{
@@ -401,9 +429,16 @@ test('normalizeColumnConfigKeys prefers an existing stored
Main entry over a loc
},
comparisonRevenueColumns,
),
- ).toEqual({
- 'Main revenue': { columnWidth: 120 },
- });
+ ).toEqual(expected);
+ expect(
+ normalizeColumnConfigKeys(
+ {
+ 'Principale revenue': { columnWidth: 300 },
+ 'Main revenue': { columnWidth: 120 },
+ },
+ comparisonRevenueColumns,
+ ),
+ ).toEqual(expected);
});
test('normalizeColumnConfigKeys returns an empty object without saved config',
() => {
@@ -422,6 +457,145 @@ test('normalizeColumnConfigKeys keeps keys that do not
match colnames', () => {
).toEqual({ leftover: { columnWidth: 10 } });
});
+test('getKnownColumnConfigKeys includes colnames, dataset columns, and
metrics', () => {
+ expect(
+ getKnownColumnConfigKeys(['__timestamp'], {
+ columns: [{ column_name: 'Time' }, { column_name: 'region' }],
+ metrics: [{ metric_name: 'count' }],
+ }),
+ ).toEqual(['__timestamp', 'Time', 'region', 'count']);
+});
+
+test('getKnownColumnConfigKeys ignores missing colnames and unnamed datasource
fields', () => {
+ expect(getKnownColumnConfigKeys()).toEqual([]);
+ expect(getKnownColumnConfigKeys(undefined, null)).toEqual([]);
+ expect(
+ getKnownColumnConfigKeys(['kept'], {
+ columns: null,
+ metrics: [{}, { metric_name: '' }],
+ }),
+ ).toEqual(['kept']);
+ expect(
+ getKnownColumnConfigKeys(['kept'], {
+ columns: [{}, { column_name: 'region' }],
+ metrics: null,
+ }),
+ ).toEqual(['kept', 'region']);
+});
+
+test('normalizeColumnConfigKeys remaps display aliases onto real column keys',
() => {
+ expect(
+ normalizeColumnConfigKeys(
+ {
+ Time: { customColumnName: 'Date' },
+ region: { columnWidth: 50 },
+ },
+ ['__timestamp', 'region'],
+ ),
+ ).toEqual({
+ __timestamp: { customColumnName: 'Date' },
+ region: { columnWidth: 50 },
+ });
+});
+
+test('normalizeColumnConfigKeys prefers an existing stored key over a display
alias', () => {
+ const expected = {
+ __timestamp: { customColumnName: 'Stored' },
+ };
+ expect(
+ normalizeColumnConfigKeys(
+ {
+ __timestamp: { customColumnName: 'Stored' },
+ Time: { customColumnName: 'Alias' },
+ },
+ ['__timestamp'],
+ ),
+ ).toEqual(expected);
+ expect(
+ normalizeColumnConfigKeys(
+ {
+ Time: { customColumnName: 'Alias' },
+ __timestamp: { customColumnName: 'Stored' },
+ },
+ ['__timestamp'],
+ ),
+ ).toEqual(expected);
+});
+
+test('normalizeColumnConfigKeys keeps a real Time column when it is not in
colnames but __timestamp is', () => {
+ expect(
+ normalizeColumnConfigKeys(
+ { Time: { customColumnName: 'Keep me' } },
+ ['__timestamp'],
+ ['Time', '__timestamp'],
+ ),
+ ).toEqual({
+ Time: { customColumnName: 'Keep me' },
+ });
+});
+
+test('normalizeColumnConfigKeys keeps a current Time column when __timestamp
is also present', () => {
+ expect(
+ normalizeColumnConfigKeys({ Time: { customColumnName: 'Keep me' } }, [
+ 'Time',
+ '__timestamp',
+ ]),
+ ).toEqual({
+ Time: { customColumnName: 'Keep me' },
+ });
+});
+
+test('normalizeColumnConfigKeys remaps English and translated Time aliases',
() => {
+ const originalAlias = COLUMN_NAME_ALIASES[DTTM_ALIAS];
+ COLUMN_NAME_ALIASES[DTTM_ALIAS] = 'Tiempo';
+
+ try {
+ expect(
+ normalizeColumnConfigKeys({ Tiempo: { customColumnName: 'Fecha' } }, [
+ '__timestamp',
+ ]),
+ ).toEqual({
+ __timestamp: { customColumnName: 'Fecha' },
+ });
+ expect(
+ normalizeColumnConfigKeys({ Time: { customColumnName: 'Date' } }, [
+ '__timestamp',
+ ]),
+ ).toEqual({
+ __timestamp: { customColumnName: 'Date' },
+ });
+ } finally {
+ COLUMN_NAME_ALIASES[DTTM_ALIAS] = originalAlias;
+ }
+});
+
+test('normalizeColumnConfigKeys skips remapping when a display alias is
ambiguous', () => {
+ const originalAliases = { ...COLUMN_NAME_ALIASES };
+ Object.keys(COLUMN_NAME_ALIASES).forEach(key => {
+ delete COLUMN_NAME_ALIASES[key];
+ });
+ Object.assign(COLUMN_NAME_ALIASES, {
+ __timestamp: 'Time',
+ other_time: 'Time',
+ });
+
+ try {
+ expect(
+ normalizeColumnConfigKeys({ Time: { customColumnName: 'Date' } }, [
+ '__timestamp',
+ 'other_time',
+ ]),
+ ).toEqual({
+ Time: { customColumnName: 'Date' },
+ });
+ } finally {
+ Object.keys(COLUMN_NAME_ALIASES).forEach(key => {
+ delete COLUMN_NAME_ALIASES[key];
+ });
+ Object.assign(COLUMN_NAME_ALIASES, originalAliases);
+ }
+});
+
test('buildHeaderGroupRows fills empty cells for ungrouped columns', () => {
const rows = buildHeaderGroupRows(chartGroups, ['revenue', 'region']);
diff --git
a/superset-frontend/packages/superset-ui-chart-controls/src/utils/headerGroups.ts
b/superset-frontend/packages/superset-ui-chart-controls/src/utils/headerGroups.ts
index aea45f87024..39fab758392 100644
---
a/superset-frontend/packages/superset-ui-chart-controls/src/utils/headerGroups.ts
+++
b/superset-frontend/packages/superset-ui-chart-controls/src/utils/headerGroups.ts
@@ -20,6 +20,7 @@
import { t } from '@apache-superset/core/translation';
import {
ComparisonType,
+ DTTM_ALIAS,
ensureIsArray,
getColumnLabel,
getMetricLabel,
@@ -29,6 +30,7 @@ import {
SqlaFormData,
} from '@superset-ui/core';
import { isEmpty, last } from 'lodash-es';
+import { COLUMN_NAME_ALIASES } from '../constants';
import {
isPercentMetric,
isRegularMetric,
@@ -61,6 +63,34 @@ export type HeaderGroupCell = {
export const TIME_COMPARE_MAIN_KEY = 'Main';
+/** English msgid for `__timestamp`; `COLUMN_NAME_ALIASES` holds `t('Time')`.
*/
+const DTTM_ALIAS_SOURCE_LABEL = 'Time';
+
+function getColumnNameAliasLabels(col: string): string[] {
+ const translated = COLUMN_NAME_ALIASES[col];
+ const source = col === DTTM_ALIAS ? DTTM_ALIAS_SOURCE_LABEL : undefined;
+ return [
+ ...new Set(
+ [source, translated].filter((label): label is string => Boolean(label)),
+ ),
+ ];
+}
+
+/** Colnames plus dataset columns/metrics, so a real `Time` name is not
remapped onto `__timestamp`. */
+export function getKnownColumnConfigKeys(
+ colnames: string[] = [],
+ datasource?: {
+ columns?: { column_name?: string }[] | null;
+ metrics?: { metric_name?: string }[] | null;
+ } | null,
+): string[] {
+ return [
+ ...colnames,
+ ...(datasource?.columns ?? []).map(col => col.column_name ?? ''),
+ ...(datasource?.metrics ?? []).map(metric => metric.metric_name ?? ''),
+ ].filter(Boolean);
+}
+
const TIME_COMPARE_SYMBOL_PREFIXES = ['#', '△', '%'] as const;
type TimeCompareSlot = {
@@ -329,22 +359,50 @@ export function syncTimeComparisonGroups(
* Remap saved comparison-column config onto locale-independent keys.
*
* Charts created before comparison slots were stored as `Main …` may still
- * keep `t('Main') …` entries (for example `Principale revenue`). The column
- * editor lists the stored keys, so those legacy entries must be rewritten
- * before it filters `column_config` down to `colnames`.
+ * keep `t('Main') …` entries (for example `Principale revenue`). A buggy
+ * column editor also wrote `COLUMN_NAME_ALIASES` display names (for example
+ * `Time` for `__timestamp`). The column editor lists the stored keys, so
+ * those legacy entries must be rewritten before it filters `column_config`
+ * down to `colnames`.
*/
export function normalizeColumnConfigKeys<T>(
value: Record<string, T> | null | undefined,
colnames: string[],
+ knownKeys: string[] = colnames,
): Record<string, T> {
if (!value) {
return {};
}
const colnamesSet = new Set(colnames);
- const next: Record<string, T> = {};
+ const knownKeySet = new Set(knownKeys);
+ const aliasToKey = new Map<string, string>();
+ const ambiguousAliases = new Set<string>();
+ colnames.forEach(col => {
+ getColumnNameAliasLabels(col).forEach(alias => {
+ // A saved key that names a real column or metric is not a legacy alias.
+ if (!alias || knownKeySet.has(alias) || ambiguousAliases.has(alias)) {
+ return;
+ }
+ const existing = aliasToKey.get(alias);
+ if (existing && existing !== col) {
+ aliasToKey.delete(alias);
+ ambiguousAliases.add(alias);
+ return;
+ }
+ aliasToKey.set(alias, col);
+ });
+ });
+ const next: Record<string, T> = Object.create(null);
Object.entries(value).forEach(([key, config]) => {
+ if (colnamesSet.has(key) || knownKeySet.has(key)) {
+ next[key] = config;
+ return;
+ }
const stored = toStoredTimeComparisonColumnKey(key, colnames);
- const target = colnamesSet.has(stored) ? stored : key;
+ let target = colnamesSet.has(stored) ? stored : key;
+ if (!colnamesSet.has(target) && aliasToKey.has(key)) {
+ target = aliasToKey.get(key) as string;
+ }
if (key === target || !(target in next)) {
next[target] = config;
}
diff --git
a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx
b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx
index af919d8b03a..13ef3b9d749 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/controlPanel.tsx
@@ -46,6 +46,7 @@ import {
ColorSchemeEnum,
getHeaderGroupsControlProps,
getTimeComparisonColumnKeys,
+ getKnownColumnConfigKeys,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import {
@@ -779,6 +780,10 @@ const config: ControlPanelConfig = {
coltypes,
childColumnMap,
timeComparisonColumnMap,
+ knownKeys: getKnownColumnConfigKeys(
+ _colnames || [],
+ explore.datasource,
+ ),
},
};
},
diff --git
a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts
b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts
index 1d168c4579c..5c142651d1e 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts
@@ -45,6 +45,8 @@ import {
ConditionalFormattingConfig,
getColorFormatters,
ColorSchemeEnum,
+ normalizeColumnConfigKeys,
+ getKnownColumnConfigKeys,
resolveHeaderGroups,
toTotalsAggregate,
} from '@superset-ui/chart-controls';
@@ -428,6 +430,11 @@ const processColumns = memoizePerChart(function
processColumns(
const metricsSet = new Set(metrics);
const percentMetricsSet = new Set(percentMetrics);
const rawPercentMetricsSet = new Set(rawPercentMetrics);
+ const normalizedColumnConfig = normalizeColumnConfigKeys(
+ columnConfig,
+ colnames || [],
+ getKnownColumnConfigKeys(colnames || [], props.datasource),
+ );
const columns: DataColumnMeta[] = (colnames || [])
.map((key: string, originalIndex: number) => ({ key, originalIndex }))
@@ -442,7 +449,7 @@ const processColumns = memoizePerChart(function
processColumns(
// an earlier column (e.g. a percent-metric-only one) got filtered
// out, which would otherwise shift every later column's dataType.
const dataType = coltypes[originalIndex];
- const config = columnConfig[key] || {};
+ const config = normalizedColumnConfig[key] || {};
// for the purpose of presentation, only numeric values are treated as
metrics
// because users can also add things like `MAX(str_col)` as a metric.
const isMetric = metricsSet.has(key) && isNumeric(key, records);
diff --git
a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/isEqualColumns.ts
b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/isEqualColumns.ts
index e907ac50ac3..0466e725928 100644
---
a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/isEqualColumns.ts
+++
b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/isEqualColumns.ts
@@ -69,6 +69,14 @@ export default function isEqualColumns(
verboseMap: isEqual(a.datasource?.verboseMap, b.datasource?.verboseMap),
currencyCodeColumn:
a.datasource?.currencyCodeColumn === b.datasource?.currencyCodeColumn,
+ knownColumnNames: isEqualArray(
+ (a.datasource?.columns ?? []).map(col => col.column_name),
+ (b.datasource?.columns ?? []).map(col => col.column_name),
+ ),
+ knownMetricNames: isEqualArray(
+ (a.datasource?.metrics ?? []).map(metric => metric.metric_name),
+ (b.datasource?.metrics ?? []).map(metric => metric.metric_name),
+ ),
detectedCurrency:
a.queriesData?.[0]?.detected_currency ===
b.queriesData?.[0]?.detected_currency,
diff --git
a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/controlPanel.test.tsx
b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/controlPanel.test.tsx
index b153eb6b6b1..a86303efbcc 100644
---
a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/controlPanel.test.tsx
+++
b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/controlPanel.test.tsx
@@ -315,10 +315,44 @@ test('column_config mapStateToProps expands comparison
columns for metrics', ()
expect(result.columnsPropsObject.colnames).toEqual(
expect.arrayContaining(['Main col1', '# col1', '△ col1', '% col1']),
);
+ expect(result.columnsPropsObject.knownKeys).toEqual(
+ expect.arrayContaining(['col1', 'col2']),
+ );
expect(result.columnsPropsObject.childColumnMap['Main col1']).toBe(false);
expect(result.columnsPropsObject.childColumnMap['# col1']).toBe(true);
});
+test('column_config knownKeys keep original Time metric after comparison
expansion', () => {
+ const controlConfig = findNamedControl('column_config');
+ const explore = {
+ ...createMockExplore(['1 year ago']),
+ form_data: {
+ ...createMockExplore(['1 year ago']).form_data,
+ metrics: ['Time'],
+ },
+ };
+ const result = controlConfig!.mapStateToProps!(
+ explore,
+ createMockControlStateForConditionalFormatting(),
+ {
+ chartStatus: 'success' as const,
+ queriesResponse: [
+ {
+ colnames: ['__timestamp', 'Time'],
+ coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
+ },
+ ],
+ },
+ );
+
+ expect(result.columnsPropsObject.colnames).toEqual(
+ expect.arrayContaining(['__timestamp', 'Main Time', '# Time']),
+ );
+ expect(result.columnsPropsObject.knownKeys).toEqual(
+ expect.arrayContaining(['__timestamp', 'Time']),
+ );
+});
+
test('metrics control includes non-filterable columns', () => {
const mapStateToProps = findMetricsMapStateToProps();
expect(mapStateToProps).toBeTruthy();
diff --git
a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/transformProps.test.ts
b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/transformProps.test.ts
index 9d7b75ff04e..0f2107425a6 100644
---
a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/transformProps.test.ts
+++
b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/transformProps.test.ts
@@ -778,3 +778,162 @@ test('drops time comparison header groups when
time_compare is empty', () => {
expect(result.headerGroups).toEqual([]);
});
+
+test('does not remap a real Previous revenue column onto Main revenue', () => {
+ const result = transformProps(
+ createMockChartProps({
+ rawFormData: {
+ viz_type: 'table',
+ datasource: '1__table',
+ query_mode: QueryMode.Raw,
+ metrics: [],
+ percent_metrics: [],
+ column_config: {
+ 'Previous revenue': { customColumnName: 'Prior' },
+ },
+ table_timestamp_format: '',
+ },
+ queriesData: [
+ {
+ data: [
+ {
+ 'Main revenue': 10,
+ 'Previous revenue': 8,
+ '# revenue': 2,
+ },
+ ],
+ colnames: ['Main revenue', 'Previous revenue', '# revenue'],
+ coltypes: [
+ GenericDataType.Numeric,
+ GenericDataType.Numeric,
+ GenericDataType.Numeric,
+ ],
+ rowcount: 1,
+ applied_filters: [],
+ rejected_filters: [],
+ },
+ ] as unknown as TableChartProps['queriesData'],
+ }),
+ );
+
+ expect(
+ result.columns.find(col => col.key === 'Previous revenue')?.config,
+ ).toEqual(expect.objectContaining({ customColumnName: 'Prior' }));
+ expect(
+ result.columns.find(col => col.key === 'Main revenue')?.config,
+ ).toEqual({});
+});
+
+test('does not apply a dropped Previous revenue dataset column config to Main
revenue', () => {
+ const result = transformProps(
+ createMockChartProps({
+ rawFormData: {
+ viz_type: 'table',
+ datasource: '1__table',
+ query_mode: QueryMode.Aggregate,
+ metrics: [],
+ percent_metrics: [],
+ column_config: {
+ 'Previous revenue': { visible: false },
+ },
+ table_timestamp_format: '',
+ },
+ datasource: {
+ columns: [{ column_name: 'Previous revenue' }],
+ metrics: [],
+ columnFormats: {},
+ currencyFormats: {},
+ verboseMap: {},
+ } as unknown as TableChartProps['datasource'],
+ queriesData: [
+ {
+ data: [{ 'Main revenue': 10, '# revenue': 2 }],
+ colnames: ['Main revenue', '# revenue'],
+ coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
+ rowcount: 1,
+ applied_filters: [],
+ rejected_filters: [],
+ },
+ ] as unknown as TableChartProps['queriesData'],
+ }),
+ );
+
+ expect(
+ result.columns.find(col => col.key === 'Main revenue')?.config,
+ ).toEqual({});
+});
+
+test('applies legacy Time column_config to the __timestamp column', () => {
+ const result = transformProps(
+ createMockChartProps({
+ rawFormData: {
+ viz_type: 'table',
+ datasource: '1__table',
+ query_mode: QueryMode.Aggregate,
+ metrics: [],
+ percent_metrics: [],
+ column_config: {
+ Time: { customColumnName: 'Date', d3TimeFormat: '%Y' },
+ },
+ table_timestamp_format: '',
+ },
+ queriesData: [
+ {
+ data: [{ __timestamp: '2020-01-01T12:34:56' }],
+ colnames: ['__timestamp'],
+ coltypes: [GenericDataType.Temporal],
+ rowcount: 1,
+ applied_filters: [],
+ rejected_filters: [],
+ },
+ ] as unknown as TableChartProps['queriesData'],
+ }),
+ );
+
+ const timestampColumn = result.columns.find(col => col.key ===
'__timestamp');
+ expect(timestampColumn?.config).toEqual(
+ expect.objectContaining({
+ customColumnName: 'Date',
+ d3TimeFormat: '%Y',
+ }),
+ );
+});
+
+test('does not apply a dropped Time dataset column config to __timestamp', ()
=> {
+ const result = transformProps(
+ createMockChartProps({
+ rawFormData: {
+ viz_type: 'table',
+ datasource: '1__table',
+ query_mode: QueryMode.Aggregate,
+ metrics: [],
+ percent_metrics: [],
+ column_config: {
+ Time: { customColumnName: 'Date', d3TimeFormat: '%Y' },
+ },
+ table_timestamp_format: '',
+ },
+ datasource: {
+ columns: [{ column_name: 'Time' }],
+ metrics: [],
+ columnFormats: {},
+ currencyFormats: {},
+ verboseMap: {},
+ } as unknown as TableChartProps['datasource'],
+ queriesData: [
+ {
+ data: [{ __timestamp: '2020-01-01T12:34:56' }],
+ colnames: ['__timestamp'],
+ coltypes: [GenericDataType.Temporal],
+ rowcount: 1,
+ applied_filters: [],
+ rejected_filters: [],
+ },
+ ] as unknown as TableChartProps['queriesData'],
+ }),
+ );
+
+ expect(result.columns.find(col => col.key ===
'__timestamp')?.config).toEqual(
+ {},
+ );
+});
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx
index c11d4dab16b..f8e8a56eb5f 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/controlPanel.tsx
@@ -34,6 +34,7 @@ import {
ControlFormItemSpec,
getStandardizedControls,
DEFAULT_TIME_FORMAT,
+ getKnownColumnConfigKeys,
} from '@superset-ui/chart-controls';
import { DEFAULT_FORM_DATA } from './types';
import { LabelPositionEnum } from '../types';
@@ -224,7 +225,14 @@ const config: ControlPanelConfig = {
| ChartDataResponseResult
| undefined,
appliedColumnNames: metricColumn,
- columnsPropsObject: { colnames, coltypes },
+ columnsPropsObject: {
+ colnames,
+ coltypes,
+ knownKeys: getKnownColumnConfigKeys(
+ colnames,
+ explore.datasource,
+ ),
+ },
};
},
},
diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
index 390ecff1a0c..603798bd9f2 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx
@@ -43,6 +43,7 @@ import {
hasMatchingDatasourceMetadata,
getHeaderGroupsControlProps,
getTimeComparisonColumnKeys,
+ getKnownColumnConfigKeys,
ConditionalFormattingConfig,
ObjectFormattingEnum,
ColorSchemeEnum,
@@ -785,6 +786,10 @@ const config: ControlPanelConfig = {
coltypes,
childColumnMap,
timeComparisonColumnMap,
+ knownKeys: getKnownColumnConfigKeys(
+ _colnames || [],
+ explore.datasource,
+ ),
},
};
},
diff --git a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
index e3b2d866c80..ac5d52de768 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/transformProps.ts
@@ -44,6 +44,8 @@ import {
ConditionalFormattingConfig,
getColorFormatters,
ColorSchemeEnum,
+ normalizeColumnConfigKeys,
+ getKnownColumnConfigKeys,
resolveHeaderGroups,
} from '@superset-ui/chart-controls';
@@ -237,6 +239,11 @@ const processColumns = memoizeOne(function processColumns(
const columnsByName = new Map(
(props.datasource.columns ?? []).map(col => [col.column_name, col]),
);
+ const normalizedColumnConfig = normalizeColumnConfigKeys(
+ columnConfig,
+ colnames || [],
+ getKnownColumnConfigKeys(colnames || [], props.datasource),
+ );
const columns: DataColumnMeta[] = (colnames || [])
.filter(
@@ -246,7 +253,7 @@ const processColumns = memoizeOne(function processColumns(
)
.map((key: string, i) => {
const dataType = coltypes[i];
- const config = columnConfig[key] || {};
+ const config = normalizedColumnConfig[key] || {};
// for the purpose of presentation, only numeric values are treated as
metrics
// because users can also add things like `MAX(str_col)` as a metric.
const isFilterable = columnsByName.get(key)?.filterable;
diff --git
a/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts
b/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts
index 0643eaab68b..2c8699624da 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts
+++ b/superset-frontend/plugins/plugin-chart-table/src/utils/isEqualColumns.ts
@@ -30,6 +30,14 @@ export default function isEqualColumns(
a.datasource.currencyFormats === b.datasource.currencyFormats &&
a.datasource.currencyCodeColumn === b.datasource.currencyCodeColumn &&
a.datasource.verboseMap === b.datasource.verboseMap &&
+ isEqualArray(
+ (a.datasource.columns ?? []).map(col => col.column_name),
+ (b.datasource.columns ?? []).map(col => col.column_name),
+ ) &&
+ isEqualArray(
+ (a.datasource.metrics ?? []).map(metric => metric.metric_name),
+ (b.datasource.metrics ?? []).map(metric => metric.metric_name),
+ ) &&
a.formData.tableTimestampFormat === b.formData.tableTimestampFormat &&
a.formData.timeGrainSqla === b.formData.timeGrainSqla &&
JSON.stringify(a.formData.columnConfig || null) ===
diff --git
a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
index 4192e9098cb..620c49134d6 100644
--- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
@@ -390,6 +390,118 @@ describe('plugin-chart-table', () => {
expect(String(parsedDate)).toBe('2020-01-01 12:34:56');
expect(parsedDate.getTime()).toBe(1577882096000);
});
+
+ test('does not remap a real Previous revenue column onto Main revenue', ()
=> {
+ const { columns } = transformProps({
+ ...testData.basic,
+ queriesData: [
+ {
+ ...testData.basic.queriesData[0],
+ colnames: ['Main revenue', 'Previous revenue', '# revenue'],
+ coltypes: [
+ GenericDataType.Numeric,
+ GenericDataType.Numeric,
+ GenericDataType.Numeric,
+ ],
+ data: [
+ {
+ 'Main revenue': 10,
+ 'Previous revenue': 8,
+ '# revenue': 2,
+ },
+ ],
+ },
+ ],
+ rawFormData: {
+ ...testData.basic.rawFormData,
+ column_config: {
+ 'Previous revenue': { customColumnName: 'Prior' },
+ },
+ },
+ });
+
+ expect(
+ columns.find(col => col.key === 'Previous revenue')?.config,
+ ).toEqual(expect.objectContaining({ customColumnName: 'Prior' }));
+ expect(columns.find(col => col.key === 'Main revenue')?.config).toEqual(
+ {},
+ );
+ });
+
+ test('does not apply a dropped Previous revenue dataset column config to
Main revenue', () => {
+ const { columns } = transformProps({
+ ...testData.basic,
+ datasource: {
+ ...testData.basic.datasource,
+ columns: [{ column_name: 'Previous revenue' }],
+ },
+ queriesData: [
+ {
+ ...testData.basic.queriesData[0],
+ colnames: ['Main revenue', '# revenue'],
+ coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
+ data: [{ 'Main revenue': 10, '# revenue': 2 }],
+ },
+ ],
+ rawFormData: {
+ ...testData.basic.rawFormData,
+ column_config: {
+ 'Previous revenue': { visible: false },
+ },
+ },
+ });
+
+ expect(columns.find(col => col.key === 'Main revenue')?.config).toEqual(
+ {},
+ );
+ });
+
+ test('applies legacy Time column_config to the __timestamp column', () => {
+ const { columns } = transformProps({
+ ...testData.basic,
+ rawFormData: {
+ ...testData.basic.rawFormData,
+ column_config: {
+ Time: { customColumnName: 'Date', d3TimeFormat: '%Y' },
+ },
+ },
+ });
+ const timestampColumn = columns.find(col => col.key === '__timestamp');
+
+ expect(timestampColumn?.config).toEqual(
+ expect.objectContaining({
+ customColumnName: 'Date',
+ d3TimeFormat: '%Y',
+ }),
+ );
+ });
+
+ test('does not apply a dropped Time dataset column config to __timestamp',
() => {
+ const { columns } = transformProps({
+ ...testData.basic,
+ datasource: {
+ ...testData.basic.datasource,
+ columns: [
+ {
+ column_name: 'Time',
+ type: 'TIMESTAMP',
+ type_generic: GenericDataType.Temporal,
+ },
+ ],
+ },
+ rawFormData: {
+ ...testData.basic.rawFormData,
+ column_config: {
+ Time: { customColumnName: 'Date', d3TimeFormat: '%Y' },
+ },
+ },
+ });
+
+ expect(columns.find(col => col.key === '__timestamp')?.config).toEqual(
+ {},
+ );
+ });
+
test('should process comparison columns when time_compare and
comparison_type are set', () => {
const transformedProps = transformProps(testData.comparison);
const comparisonColumns = transformedProps.columns.filter(
diff --git
a/superset-frontend/plugins/plugin-chart-table/test/controlPanel.test.tsx
b/superset-frontend/plugins/plugin-chart-table/test/controlPanel.test.tsx
index 30cee93b656..6fb6ba355cb 100644
--- a/superset-frontend/plugins/plugin-chart-table/test/controlPanel.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/test/controlPanel.test.tsx
@@ -906,10 +906,44 @@ test('column_config mapStateToProps expands comparison
columns for metrics', ()
expect(result.columnsPropsObject.colnames).toEqual(
expect.arrayContaining(['Main col1', '# col1', '△ col1', '% col1']),
);
+ expect(result.columnsPropsObject.knownKeys).toEqual(
+ expect.arrayContaining(['col1', 'col2']),
+ );
expect(result.columnsPropsObject.childColumnMap['Main col1']).toBe(false);
expect(result.columnsPropsObject.childColumnMap['# col1']).toBe(true);
});
+test('column_config knownKeys keep original Time metric after comparison
expansion', () => {
+ const controlConfig = findNamedControl('column_config');
+ const explore = {
+ ...createMockExplore(['1 year ago']),
+ form_data: {
+ ...createMockExplore(['1 year ago']).form_data,
+ metrics: ['Time'],
+ },
+ };
+ const result = controlConfig!.mapStateToProps!(
+ explore,
+ createMockControlStateForConditionalFormatting(),
+ {
+ chartStatus: 'success' as const,
+ queriesResponse: [
+ {
+ colnames: ['__timestamp', 'Time'],
+ coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
+ },
+ ],
+ },
+ );
+
+ expect(result.columnsPropsObject.colnames).toEqual(
+ expect.arrayContaining(['__timestamp', 'Main Time', '# Time']),
+ );
+ expect(result.columnsPropsObject.knownKeys).toEqual(
+ expect.arrayContaining(['__timestamp', 'Time']),
+ );
+});
+
test.each([false, true])(
'numeric-leading provider uid retains pagination capability and reset
advice: %s',
supportsOffset => {
diff --git
a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.test.tsx
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.test.tsx
index 2637e1134a0..08f83a349bd 100644
---
a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.test.tsx
+++
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.test.tsx
@@ -84,3 +84,78 @@ test('keeps remapped Main column settings when another
column is edited', async
region: { columnWidth: 50 },
});
});
+
+test('stores config for an aliased column under its real column key', async ()
=> {
+ const onChange = jest.fn();
+ render(
+ <ColumnConfigControl
+ name="column_config"
+ value={{
+ __timestamp: { customColumnName: 'Date' },
+ }}
+ onChange={onChange}
+ columnsPropsObject={{
+ colnames: ['__timestamp'],
+ coltypes: [GenericDataType.Temporal],
+ }}
+ />,
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Configure Time'
}));
+
+ expect(onChange).toHaveBeenCalledWith({
+ __timestamp: { customColumnName: 'Date', columnWidth: 50 },
+ });
+});
+
+test('migrates legacy Time alias config onto __timestamp when edited', async
() => {
+ const onChange = jest.fn();
+ render(
+ <ColumnConfigControl
+ name="column_config"
+ value={{
+ Time: { customColumnName: 'Date' },
+ }}
+ onChange={onChange}
+ columnsPropsObject={{
+ colnames: ['__timestamp', 'region'],
+ coltypes: [GenericDataType.Temporal, GenericDataType.String],
+ }}
+ />,
+ );
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Configure region' }),
+ );
+
+ expect(onChange).toHaveBeenCalledWith({
+ __timestamp: { customColumnName: 'Date' },
+ region: { columnWidth: 50 },
+ });
+});
+
+test('does not store a known Time column on __timestamp when another column is
edited', async () => {
+ const onChange = jest.fn();
+ render(
+ <ColumnConfigControl
+ name="column_config"
+ value={{
+ Time: { customColumnName: 'Date' },
+ }}
+ onChange={onChange}
+ columnsPropsObject={{
+ colnames: ['__timestamp', 'region'],
+ coltypes: [GenericDataType.Temporal, GenericDataType.String],
+ knownKeys: ['Time', '__timestamp', 'region'],
+ }}
+ />,
+ );
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Configure region' }),
+ );
+
+ expect(onChange).toHaveBeenCalledWith({
+ region: { columnWidth: 50 },
+ });
+});
diff --git
a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.tsx
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.tsx
index 2481fc0bbfd..6647e95641b 100644
---
a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.tsx
+++
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.tsx
@@ -42,6 +42,7 @@ export type ColumnConfigControlProps<T extends ColumnConfig> =
coltypes: GenericDataType[];
childColumnMap?: Record<string, boolean>;
timeComparisonColumnMap?: Record<string, boolean>;
+ knownKeys?: string[];
};
configFormLayout?: ColumnConfigFormLayout;
appliedColumnNames?: string[];
@@ -67,7 +68,11 @@ export default function ColumnConfigControl<T extends
ColumnConfig>({
height,
...props
}: ColumnConfigControlProps<T>) {
- const { colnames: _colnames, coltypes: _coltypes } = columnsPropsObject ||
{};
+ const {
+ colnames: _colnames,
+ coltypes: _coltypes,
+ knownKeys,
+ } = columnsPropsObject || {};
let colnames: string[] = [];
let coltypes: GenericDataType[] = [];
if (appliedColumnNames.length === 0) {
@@ -83,15 +88,20 @@ export default function ColumnConfigControl<T extends
ColumnConfig>({
});
}
const theme = useTheme();
+ const columnConfigKnownKeys = useMemo(
+ () => [...new Set([...(knownKeys ?? []), ...colnames])],
+ [colnames, knownKeys],
+ );
const normalizedValue = useMemo(
- () => normalizeColumnConfigKeys(value, colnames),
- [colnames, value],
+ () => normalizeColumnConfigKeys(value, colnames, columnConfigKnownKeys),
+ [colnames, columnConfigKnownKeys, value],
);
const columnConfigs = useMemo(() => {
const configs: Record<string, ColumnConfigInfo> = {};
colnames?.forEach((col, idx) => {
configs[col] = {
+ key: col,
name: COLUMN_NAME_ALIASES[col] || col,
type: coltypes?.[idx],
config: normalizedValue[col] || {},
@@ -145,9 +155,9 @@ export default function ColumnConfigControl<T extends
ColumnConfig>({
>
{columnsWithChildInfo.map(col => (
<ColumnConfigItem
- key={col.name}
+ key={col.key}
column={col}
- onChange={config => setColumnConfig(col.name, config as T)}
+ onChange={config => setColumnConfig(col.key, config as T)}
configFormLayout={
col.isTimeComparisonColumn
? ({
diff --git
a/superset-frontend/src/explore/components/controls/ColumnConfigControl/types.ts
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/types.ts
index a689c754fbd..6461617452f 100644
---
a/superset-frontend/src/explore/components/controls/ColumnConfigControl/types.ts
+++
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/types.ts
@@ -41,6 +41,7 @@ export type ColumnConfig = {
export interface ColumnConfigInfo {
isChildColumn: boolean;
isTimeComparisonColumn: boolean;
+ key: string;
name: string;
type?: GenericDataType;
config: JsonObject;