This is an automated email from the ASF dual-hosted git repository.
jli 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 1a77e171799 fix(chart-customizations): support migration of dynamic
group by (#37176)
1a77e171799 is described below
commit 1a77e1717995402a8a87a433f34009cf38ddc96c
Author: Damian Pendrak <[email protected]>
AuthorDate: Fri Feb 20 22:11:07 2026 +0100
fix(chart-customizations): support migration of dynamic group by (#37176)
---
.../superset-ui-core/src/query/types/Dashboard.ts | 29 ++
superset-frontend/src/dashboard/actions/hydrate.ts | 8 +-
.../FilterBar/FilterControls/FilterControls.tsx | 6 +-
.../components/nativeFilters/FilterBar/state.ts | 12 +-
.../nativeFilters/FiltersConfigModal/utils.ts | 7 +-
.../dashboard/components/nativeFilters/state.ts | 43 +-
.../util/migrateChartCustomization.test.ts | 490 +++++++++++++++++++++
.../dashboard/util/migrateChartCustomization.ts | 155 +++++++
superset-frontend/src/dataMask/reducer.ts | 14 +-
9 files changed, 744 insertions(+), 20 deletions(-)
diff --git
a/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts
b/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts
index a2eb7979c4e..bcf84355665 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Dashboard.ts
@@ -240,4 +240,33 @@ export type DashboardComponentMetadata = {
dataMask: DataMaskStateWithId;
};
+export interface LegacyChartCustomizationDataset {
+ value: number | string;
+ label: string;
+ table_name?: string;
+}
+
+export interface LegacyChartCustomizationConfig {
+ name: string;
+ dataset: string | number | LegacyChartCustomizationDataset | null;
+ column: string | string[] | null;
+ sortAscending?: boolean;
+ sortMetric?: string;
+ canSelectMultiple?: boolean;
+ defaultDataMask?: DataMask;
+ controlValues?: {
+ enableEmptyFilter?: boolean;
+ [key: string]: any;
+ };
+ description?: string;
+}
+
+export interface LegacyChartCustomizationItem {
+ id: string;
+ title?: string;
+ removed?: boolean;
+ chartId?: number;
+ customization: LegacyChartCustomizationConfig;
+}
+
export default {};
diff --git a/superset-frontend/src/dashboard/actions/hydrate.ts
b/superset-frontend/src/dashboard/actions/hydrate.ts
index 5bcb8574435..46396c336e8 100644
--- a/superset-frontend/src/dashboard/actions/hydrate.ts
+++ b/superset-frontend/src/dashboard/actions/hydrate.ts
@@ -60,6 +60,7 @@ import { ResourceStatus } from
'src/hooks/apiResources/apiResources';
import type { DashboardChartStates } from 'src/dashboard/types/chartState';
import extractUrlParams from '../util/extractUrlParams';
import updateComponentParentsList from '../util/updateComponentParentsList';
+import { migrateChartCustomizationArray } from
'../util/migrateChartCustomization';
import {
DashboardLayout,
FilterBarOrientation,
@@ -291,8 +292,13 @@ export const hydrateDashboard =
directPathToChild.push(directLinkComponentId);
}
- const chartCustomizations =
+ const rawChartCustomizations =
(metadata?.chart_customization_config as JsonObject[]) || [];
+
+ const chartCustomizations = migrateChartCustomizationArray(
+ rawChartCustomizations,
+ );
+
const filters =
(metadata?.native_filter_configuration as JsonObject[]) || [];
const combinedFilters = [...filters, ...chartCustomizations];
diff --git
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
index 545496aec34..49ebc4b5866 100644
---
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
+++
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControls.tsx
@@ -279,10 +279,14 @@ const FilterControls: FC<FilterControlsProps> = ({
/>
);
}
+ const filterWithDataMask = addDataMaskToCustomization(
+ item,
+ dataMaskSelected,
+ );
return (
<FilterControl
key={item.id}
- filter={addDataMaskToCustomization(item, dataMaskSelected)}
+ filter={filterWithDataMask}
dataMaskSelected={dataMaskSelected}
onFilterSelectionChange={(_, dataMask) =>
handleChartCustomizationChange(item, dataMask)
diff --git
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts
index 1191edf5e69..ed6a7634f18 100644
---
a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts
+++
b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts
@@ -30,6 +30,7 @@ import { ChartsState, RootState } from 'src/dashboard/types';
import {
NATIVE_FILTER_PREFIX,
CHART_CUSTOMIZATION_PREFIX,
+ LEGACY_GROUPBY_PREFIX,
isNativeFilter,
} from '../FiltersConfigModal/utils';
import { useFilterConfiguration } from '../state';
@@ -87,7 +88,8 @@ export const useAllAppliedDataMask = () => {
const id = String(item.id);
return (
id.startsWith(NATIVE_FILTER_PREFIX) ||
- id.startsWith(CHART_CUSTOMIZATION_PREFIX)
+ id.startsWith(CHART_CUSTOMIZATION_PREFIX) ||
+ id.startsWith(LEGACY_GROUPBY_PREFIX)
);
})
.reduce(
@@ -108,10 +110,10 @@ export const useFilterUpdates = (
const dataMaskApplied = useNativeFiltersDataMask();
useEffect(() => {
Object.keys(dataMaskSelected).forEach(selectedId => {
- const isChartCustomization = String(selectedId).startsWith(
- CHART_CUSTOMIZATION_PREFIX,
- );
- if (!isChartCustomization && !filters[selectedId]) {
+ const isChartCustomizationItem =
+ String(selectedId).startsWith(CHART_CUSTOMIZATION_PREFIX) ||
+ String(selectedId).startsWith(LEGACY_GROUPBY_PREFIX);
+ if (!isChartCustomizationItem && !filters[selectedId]) {
setDataMaskSelected(draft => {
delete draft[selectedId];
});
diff --git
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts
index 199d464a559..c69bf424303 100644
---
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts
+++
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts
@@ -310,9 +310,11 @@ export const createHandleCustomizationSave =
export const CHART_CUSTOMIZATION_PREFIX = 'CHART_CUSTOMIZATION-';
export const CHART_CUSTOMIZATION_DIVIDER_PREFIX =
'CHART_CUSTOMIZATION_DIVIDER-';
+export const LEGACY_GROUPBY_PREFIX = 'groupby_';
export const isChartCustomization = (id: string): boolean =>
- id.startsWith(CHART_CUSTOMIZATION_PREFIX);
+ id.startsWith(CHART_CUSTOMIZATION_PREFIX) ||
+ id.startsWith(LEGACY_GROUPBY_PREFIX);
export const isChartCustomizationDivider = (id: string): boolean =>
id.startsWith(CHART_CUSTOMIZATION_DIVIDER_PREFIX);
@@ -337,7 +339,8 @@ export const isFilterId = (id: string): boolean =>
export const isChartCustomizationId = (id: string): boolean =>
id.startsWith(CHART_CUSTOMIZATION_PREFIX) ||
- id.startsWith(CHART_CUSTOMIZATION_DIVIDER_PREFIX);
+ id.startsWith(CHART_CUSTOMIZATION_DIVIDER_PREFIX) ||
+ id.startsWith(LEGACY_GROUPBY_PREFIX);
export const getItemType = (id: string): ItemType => {
if (isFilterId(id)) return 'filter';
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/state.ts
b/superset-frontend/src/dashboard/components/nativeFilters/state.ts
index ecbb56e47a0..11034ab1da0 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/state.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/state.ts
@@ -32,6 +32,10 @@ import { FilterElement } from
'./FilterBar/FilterControls/types';
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
import { isChartCustomizationId } from './FiltersConfigModal/utils';
+import {
+ migrateChartCustomizationArray,
+ isLegacyChartCustomizationFormat,
+} from '../../util/migrateChartCustomization';
const EMPTY_ARRAY: ChartCustomizationConfiguration = [];
const defaultFilterConfiguration: (Filter | Divider)[] = [];
@@ -93,11 +97,19 @@ const selectDashboardChartIds = createSelector(
const selectChartCustomizationConfiguration = createSelector(
[
(state: RootState) =>
- state.dashboardInfo.metadata?.chart_customization_config || EMPTY_ARRAY,
+ state.dashboardInfo?.metadata?.chart_customization_config || EMPTY_ARRAY,
selectDashboardChartIds,
],
- (allCustomizations, dashboardChartIds): ChartCustomizationConfiguration =>
- allCustomizations.filter(customization => {
+ (allCustomizations, dashboardChartIds): ChartCustomizationConfiguration => {
+ const hasLegacyFormat = allCustomizations.some(item =>
+ isLegacyChartCustomizationFormat(item),
+ );
+
+ const migratedCustomizations = hasLegacyFormat
+ ? migrateChartCustomizationArray(allCustomizations)
+ : (allCustomizations as ChartCustomizationConfiguration);
+
+ return migratedCustomizations.filter(customization => {
if (
!customization.chartsInScope ||
customization.chartsInScope.length === 0
@@ -108,7 +120,8 @@ const selectChartCustomizationConfiguration =
createSelector(
return customization.chartsInScope.some((chartId: number) =>
dashboardChartIds.has(chartId),
);
- }),
+ });
+ },
);
export function useChartCustomizationConfiguration() {
@@ -270,10 +283,20 @@ export function useIsCustomizationInScope() {
(customization: ChartCustomization | ChartCustomizationDivider) => {
if ('title' in customization) return true;
- const isChartInScope =
+ const hasChartsInScope =
Array.isArray(customization.chartsInScope) &&
- customization.chartsInScope.length > 0 &&
- customization.chartsInScope.some((chartId: number) => {
+ customization.chartsInScope.length > 0;
+ const hasTabsInScope =
+ Array.isArray(customization.tabsInScope) &&
+ customization.tabsInScope.length > 0;
+
+ if (!hasChartsInScope && !hasTabsInScope) {
+ return true;
+ }
+
+ const isChartInScope =
+ hasChartsInScope &&
+ customization.chartsInScope!.some((chartId: number) => {
const tabParents = selectChartTabParents(chartId);
// Note: every() returns true for empty arrays, so length check is
unnecessary
return (
@@ -281,9 +304,9 @@ export function useIsCustomizationInScope() {
);
});
- const isCustomizationInActiveTab = customization.tabsInScope?.some(tab =>
- activeTabs.includes(tab),
- );
+ const isCustomizationInActiveTab =
+ hasTabsInScope &&
+ customization.tabsInScope!.some(tab => activeTabs.includes(tab));
return isChartInScope || isCustomizationInActiveTab;
},
diff --git
a/superset-frontend/src/dashboard/util/migrateChartCustomization.test.ts
b/superset-frontend/src/dashboard/util/migrateChartCustomization.test.ts
new file mode 100644
index 00000000000..28784c7378a
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/migrateChartCustomization.test.ts
@@ -0,0 +1,490 @@
+/**
+ * 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 { ChartCustomizationType } from '@superset-ui/core';
+import { ChartCustomizationPlugins } from 'src/constants';
+import {
+ isLegacyChartCustomizationFormat,
+ migrateChartCustomization,
+ migrateChartCustomizationArray,
+} from './migrateChartCustomization';
+import { DASHBOARD_ROOT_ID } from './constants';
+
+test('isLegacyChartCustomizationFormat detects legacy format', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: 1,
+ column: 'country',
+ },
+ };
+ expect(isLegacyChartCustomizationFormat(legacy)).toBe(true);
+});
+
+test('isLegacyChartCustomizationFormat rejects new format', () => {
+ const newFormat = {
+ id: 'CUSTOMIZATION-1',
+ type: ChartCustomizationType.ChartCustomization,
+ name: 'Test',
+ filterType: ChartCustomizationPlugins.DynamicGroupBy,
+ targets: [],
+ };
+ expect(isLegacyChartCustomizationFormat(newFormat)).toBe(false);
+});
+
+test('isLegacyChartCustomizationFormat rejects null', () => {
+ expect(isLegacyChartCustomizationFormat(null)).toBe(false);
+});
+
+test('isLegacyChartCustomizationFormat rejects undefined', () => {
+ expect(isLegacyChartCustomizationFormat(undefined)).toBe(false);
+});
+
+test('isLegacyChartCustomizationFormat rejects string', () => {
+ expect(isLegacyChartCustomizationFormat('string')).toBe(false);
+});
+
+test('isLegacyChartCustomizationFormat rejects empty object', () => {
+ expect(isLegacyChartCustomizationFormat({})).toBe(false);
+});
+
+test('migrateChartCustomization handles basic legacy format', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ chartId: 123,
+ customization: {
+ name: 'Country Filter',
+ dataset: 1,
+ column: 'country',
+ sortAscending: true,
+ sortMetric: 'count',
+ canSelectMultiple: true,
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.id).toBe('CUSTOMIZATION-1');
+ expect(result.type).toBe(ChartCustomizationType.ChartCustomization);
+ expect(result.name).toBe('Country Filter');
+ expect(result.filterType).toBe(ChartCustomizationPlugins.DynamicGroupBy);
+ expect(result.targets).toEqual([
+ {
+ datasetId: 1,
+ column: { name: 'country' },
+ },
+ ]);
+ expect(result.scope).toEqual({
+ rootPath: [DASHBOARD_ROOT_ID],
+ excluded: [],
+ });
+ expect(result.chartsInScope).toEqual([123]);
+ expect(result.tabsInScope).toBeUndefined();
+ expect(result.cascadeParentIds).toEqual([]);
+ expect(result.controlValues).toEqual({
+ sortAscending: true,
+ sortMetric: 'count',
+ canSelectMultiple: true,
+ });
+});
+
+test('migrateChartCustomization handles dataset as string', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: '42',
+ column: 'country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.targets[0].datasetId).toBe(42);
+});
+
+test('migrateChartCustomization handles dataset as object', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: {
+ value: 42,
+ label: 'My Dataset',
+ table_name: 'my_table',
+ },
+ column: 'country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.targets[0].datasetId).toBe(42);
+});
+
+test('migrateChartCustomization handles dataset object with string value', ()
=> {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: {
+ value: '99',
+ label: 'My Dataset',
+ },
+ column: 'country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.targets[0].datasetId).toBe(99);
+});
+
+test('migrateChartCustomization handles column as array', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: 1,
+ column: ['country', 'region'],
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.targets[0].column?.name).toBe('country');
+});
+
+test('migrateChartCustomization handles empty column array', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: 1,
+ column: [],
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.targets[0].column?.name).toBe('');
+});
+
+test('migrateChartCustomization handles missing chartId', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: 1,
+ column: 'country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.chartsInScope).toBeUndefined();
+});
+
+test('migrateChartCustomization uses title as fallback for name', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ title: 'Fallback Title',
+ customization: {
+ name: '',
+ dataset: 1,
+ column: 'country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.name).toBe('Fallback Title');
+});
+
+test('migrateChartCustomization prefers customization.name over title', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ title: 'Fallback Title',
+ customization: {
+ name: 'Primary Name',
+ dataset: 1,
+ column: 'country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.name).toBe('Primary Name');
+});
+
+test('migrateChartCustomization enhances defaultDataMask with groupby', () => {
+ const dataMask = {
+ extraFormData: { filters: [] },
+ filterState: { value: ['USA'] },
+ };
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: 1,
+ column: 'country',
+ defaultDataMask: dataMask,
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.defaultDataMask).toEqual({
+ extraFormData: {
+ filters: [],
+ custom_form_data: {
+ groupby: ['USA'],
+ },
+ },
+ filterState: {
+ value: ['USA'],
+ label: 'USA',
+ },
+ });
+});
+
+test('migrateChartCustomization provides default dataMask when missing', () =>
{
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: 1,
+ column: 'country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.defaultDataMask).toEqual({
+ extraFormData: {},
+ filterState: {},
+ });
+});
+
+test('migrateChartCustomization merges controlValues', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: 1,
+ column: 'country',
+ sortAscending: false,
+ controlValues: {
+ enableEmptyFilter: true,
+ customSetting: 'value',
+ },
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.controlValues).toEqual({
+ sortAscending: false,
+ sortMetric: undefined,
+ canSelectMultiple: undefined,
+ enableEmptyFilter: true,
+ customSetting: 'value',
+ });
+});
+
+test('migrateChartCustomization preserves removed flag', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ removed: true,
+ customization: {
+ name: 'Test',
+ dataset: 1,
+ column: 'country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.removed).toBe(true);
+});
+
+test('migrateChartCustomization preserves description', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: 1,
+ column: 'country',
+ description: 'Filter by country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.description).toBe('Filter by country');
+});
+
+test('migrateChartCustomization handles null dataset', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: null,
+ column: 'country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.targets[0].datasetId).toBe(0);
+});
+
+test('migrateChartCustomization handles null column', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: 1,
+ column: null,
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.targets[0].column?.name).toBe('');
+});
+
+test('migrateChartCustomization handles non-numeric string dataset', () => {
+ const legacy = {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Test',
+ dataset: 'not-a-number',
+ column: 'country',
+ },
+ };
+
+ const result = migrateChartCustomization(legacy);
+
+ expect(result.targets[0].datasetId).toBe(0);
+});
+
+test('migrateChartCustomizationArray migrates mixed array', () => {
+ const items = [
+ {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'Legacy',
+ dataset: 1,
+ column: 'country',
+ },
+ },
+ {
+ id: 'CUSTOMIZATION-2',
+ type: ChartCustomizationType.ChartCustomization,
+ name: 'Already Migrated',
+ filterType: ChartCustomizationPlugins.DynamicGroupBy,
+ targets: [{ datasetId: 2, column: { name: 'region' } }],
+ scope: { rootPath: [DASHBOARD_ROOT_ID], excluded: [] },
+ chartsInScope: [],
+ tabsInScope: [],
+ cascadeParentIds: [],
+ defaultDataMask: { extraFormData: {}, filterState: {} },
+ controlValues: {},
+ },
+ ];
+
+ const result = migrateChartCustomizationArray(items);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].type).toBe(ChartCustomizationType.ChartCustomization);
+ expect(result[0].name).toBe('Legacy');
+ expect(result[1].name).toBe('Already Migrated');
+});
+
+test('migrateChartCustomizationArray handles empty array', () => {
+ const result = migrateChartCustomizationArray([]);
+ expect(result).toEqual([]);
+});
+
+test('migrateChartCustomizationArray handles all legacy items', () => {
+ const items = [
+ {
+ id: 'CUSTOMIZATION-1',
+ customization: {
+ name: 'First',
+ dataset: 1,
+ column: 'col1',
+ },
+ },
+ {
+ id: 'CUSTOMIZATION-2',
+ customization: {
+ name: 'Second',
+ dataset: 2,
+ column: 'col2',
+ },
+ },
+ ];
+
+ const result = migrateChartCustomizationArray(items);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].type).toBe(ChartCustomizationType.ChartCustomization);
+ expect(result[1].type).toBe(ChartCustomizationType.ChartCustomization);
+ expect(result[0].name).toBe('First');
+ expect(result[1].name).toBe('Second');
+});
+
+test('migrateChartCustomizationArray handles all new format items', () => {
+ const items = [
+ {
+ id: 'CUSTOMIZATION-1',
+ type: ChartCustomizationType.ChartCustomization,
+ name: 'First',
+ filterType: ChartCustomizationPlugins.DynamicGroupBy,
+ targets: [{ datasetId: 1, column: { name: 'col1' } }],
+ scope: { rootPath: [DASHBOARD_ROOT_ID], excluded: [] },
+ chartsInScope: [],
+ tabsInScope: [],
+ cascadeParentIds: [],
+ defaultDataMask: { extraFormData: {}, filterState: {} },
+ controlValues: {},
+ },
+ {
+ id: 'CUSTOMIZATION-2',
+ type: ChartCustomizationType.ChartCustomization,
+ name: 'Second',
+ filterType: ChartCustomizationPlugins.DynamicGroupBy,
+ targets: [{ datasetId: 2, column: { name: 'col2' } }],
+ scope: { rootPath: [DASHBOARD_ROOT_ID], excluded: [] },
+ chartsInScope: [],
+ tabsInScope: [],
+ cascadeParentIds: [],
+ defaultDataMask: { extraFormData: {}, filterState: {} },
+ controlValues: {},
+ },
+ ];
+
+ const result = migrateChartCustomizationArray(items);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].name).toBe('First');
+ expect(result[1].name).toBe('Second');
+});
diff --git a/superset-frontend/src/dashboard/util/migrateChartCustomization.ts
b/superset-frontend/src/dashboard/util/migrateChartCustomization.ts
new file mode 100644
index 00000000000..e658c57c0f4
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/migrateChartCustomization.ts
@@ -0,0 +1,155 @@
+/**
+ * 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 {
+ ChartCustomization,
+ ChartCustomizationType,
+ LegacyChartCustomizationItem,
+ LegacyChartCustomizationDataset,
+} from '@superset-ui/core';
+import { ChartCustomizationPlugins } from 'src/constants';
+import { DASHBOARD_ROOT_ID } from './constants';
+
+export function isLegacyChartCustomizationFormat(
+ item: unknown,
+): item is LegacyChartCustomizationItem {
+ return (
+ typeof item === 'object' &&
+ item !== null &&
+ 'customization' in item &&
+ !('type' in item)
+ );
+}
+
+function extractDatasetId(
+ dataset: string | number | LegacyChartCustomizationDataset | null,
+): number {
+ if (dataset === null) {
+ return 0;
+ }
+ if (typeof dataset === 'number') {
+ return dataset;
+ }
+ if (typeof dataset === 'string') {
+ const parsed = Number.parseInt(dataset, 10);
+ return Number.isNaN(parsed) ? 0 : parsed;
+ }
+ if (typeof dataset === 'object') {
+ const { value } = dataset;
+ return typeof value === 'number'
+ ? value
+ : Number.parseInt(String(value), 10) || 0;
+ }
+ return 0;
+}
+
+function extractColumnName(column: string | string[] | null): string {
+ if (column === null) {
+ return '';
+ }
+ if (Array.isArray(column)) {
+ return column[0] || '';
+ }
+ return column;
+}
+
+export function migrateChartCustomization(
+ legacy: LegacyChartCustomizationItem,
+): ChartCustomization {
+ const { customization } = legacy;
+ const datasetId = extractDatasetId(customization.dataset);
+ const columnName = extractColumnName(customization.column);
+
+ const controlValues: ChartCustomization['controlValues'] = {
+ sortAscending: customization.sortAscending,
+ sortMetric: customization.sortMetric,
+ canSelectMultiple: customization.canSelectMultiple,
+ };
+
+ if (customization.controlValues) {
+ Object.assign(controlValues, customization.controlValues);
+ }
+
+ let defaultDataMask = customization.defaultDataMask || {
+ extraFormData: {},
+ filterState: {},
+ };
+
+ const filterStateValue = defaultDataMask.filterState?.value;
+ if (filterStateValue) {
+ const groupbyValue = Array.isArray(filterStateValue)
+ ? filterStateValue
+ : [filterStateValue];
+
+ defaultDataMask = {
+ ...defaultDataMask,
+ extraFormData: {
+ ...defaultDataMask.extraFormData,
+ custom_form_data: {
+ ...((defaultDataMask.extraFormData as Record<string, unknown>)
+ ?.custom_form_data as Record<string, unknown>),
+ groupby: groupbyValue,
+ },
+ },
+ filterState: {
+ ...defaultDataMask.filterState,
+ label: defaultDataMask.filterState?.label || groupbyValue.join(', '),
+ value: filterStateValue,
+ },
+ };
+ }
+
+ const migrated: ChartCustomization = {
+ id: legacy.id,
+ type: ChartCustomizationType.ChartCustomization,
+ name: customization.name || legacy.title || '',
+ filterType: ChartCustomizationPlugins.DynamicGroupBy,
+ targets: [
+ {
+ datasetId,
+ column: {
+ name: columnName,
+ },
+ },
+ ],
+ scope: {
+ rootPath: [DASHBOARD_ROOT_ID],
+ excluded: [],
+ },
+ chartsInScope: legacy.chartId ? [legacy.chartId] : undefined,
+ tabsInScope: undefined,
+ cascadeParentIds: [],
+ defaultDataMask,
+ controlValues,
+ description: customization.description,
+ removed: legacy.removed,
+ };
+
+ return migrated;
+}
+
+export function migrateChartCustomizationArray(
+ items: unknown[],
+): ChartCustomization[] {
+ return items.map(item => {
+ if (isLegacyChartCustomizationFormat(item)) {
+ return migrateChartCustomization(item);
+ }
+ return item as ChartCustomization;
+ });
+}
diff --git a/superset-frontend/src/dataMask/reducer.ts
b/superset-frontend/src/dataMask/reducer.ts
index b049c0efde2..011cc49440c 100644
--- a/superset-frontend/src/dataMask/reducer.ts
+++ b/superset-frontend/src/dataMask/reducer.ts
@@ -37,6 +37,10 @@ import {
} from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import { SaveFilterChangesType } from
'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
+import {
+ migrateChartCustomizationArray,
+ isLegacyChartCustomizationFormat,
+} from 'src/dashboard/util/migrateChartCustomization';
import { isEqual } from 'lodash';
import {
AnyDataMaskAction,
@@ -222,9 +226,17 @@ const dataMaskReducer = produce(
loadedDataMask,
);
- const chartCustomizationConfig =
+ const rawChartCustomizationConfig =
metadata?.chart_customization_config || [];
+ const hasLegacyFormat = rawChartCustomizationConfig.some(item =>
+ isLegacyChartCustomizationFormat(item),
+ );
+
+ const chartCustomizationConfig = hasLegacyFormat
+ ? migrateChartCustomizationArray(rawChartCustomizationConfig)
+ : (rawChartCustomizationConfig as ChartCustomization[]);
+
chartCustomizationConfig.forEach(item => {
if (!isChartCustomizationItem(item)) {
return;