From c4393b79028136b87bbc173eb4ade95f449e7c75 Mon Sep 17 00:00:00 2001 From: Damian Pendrak Date: Thu, 15 Jan 2026 18:36:32 +0100 Subject: [PATCH 1/3] support migration groupby filters --- .../src/query/types/Dashboard.ts | 29 ++ .../src/dashboard/actions/hydrate.ts | 8 +- .../FilterControls/FilterControls.tsx | 6 +- .../nativeFilters/FilterBar/state.ts | 12 +- .../nativeFilters/FiltersConfigModal/utils.ts | 7 +- .../components/nativeFilters/state.ts | 41 +- .../util/migrateChartCustomization.test.ts | 490 ++++++++++++++++++ .../util/migrateChartCustomization.ts | 156 ++++++ superset-frontend/src/dataMask/reducer.ts | 14 +- 9 files changed, 744 insertions(+), 19 deletions(-) create mode 100644 superset-frontend/src/dashboard/util/migrateChartCustomization.test.ts create mode 100644 superset-frontend/src/dashboard/util/migrateChartCustomization.ts 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 a2eb7979c4ee..bcf843556657 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 5bcb85744351..46396c336e8c 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 545496aec345..49ebc4b58663 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 = ({ /> ); } + const filterWithDataMask = addDataMaskToCustomization( + item, + dataMaskSelected, + ); return ( 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 1191edf5e69d..ed6a7634f180 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 199d464a559d..c69bf4243035 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 f95c1bae4efa..2ce26173c9d8 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)[] = []; @@ -96,8 +100,16 @@ const selectChartCustomizationConfiguration = createSelector( 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() { @@ -271,10 +284,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); return ( !tabParents || @@ -283,9 +306,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 000000000000..28784c7378a2 --- /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 000000000000..ca6912c5957b --- /dev/null +++ b/superset-frontend/src/dashboard/util/migrateChartCustomization.ts @@ -0,0 +1,156 @@ +/** + * 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) + ?.custom_form_data as Record), + 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 b049c0efde2b..011cc49440c1 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; From 1f8d9f2c95bbd58563832d3c1c2e2888051f68fc Mon Sep 17 00:00:00 2001 From: Damian Pendrak Date: Thu, 15 Jan 2026 18:56:42 +0100 Subject: [PATCH 2/3] Fix unsafe selector access --- .../src/dashboard/components/nativeFilters/state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/src/dashboard/components/nativeFilters/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/state.ts index 2ce26173c9d8..a1bd627eb0c5 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/state.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/state.ts @@ -97,7 +97,7 @@ 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 => { From e492958cf09c14e3e11755cbe872f517af9e4a70 Mon Sep 17 00:00:00 2001 From: Damian Pendrak Date: Thu, 15 Jan 2026 19:27:01 +0100 Subject: [PATCH 3/3] Prettier --- .../src/dashboard/util/migrateChartCustomization.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/superset-frontend/src/dashboard/util/migrateChartCustomization.ts b/superset-frontend/src/dashboard/util/migrateChartCustomization.ts index ca6912c5957b..e658c57c0f4a 100644 --- a/superset-frontend/src/dashboard/util/migrateChartCustomization.ts +++ b/superset-frontend/src/dashboard/util/migrateChartCustomization.ts @@ -108,8 +108,7 @@ export function migrateChartCustomization( }, filterState: { ...defaultDataMask.filterState, - label: - defaultDataMask.filterState?.label || groupbyValue.join(', '), + label: defaultDataMask.filterState?.label || groupbyValue.join(', '), value: filterStateValue, }, };