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;

Reply via email to