Compare commits

...

9 Commits

Author SHA1 Message Date
Joe Li
b05a915adf Merge branch 'master' into fix-echart-save-issue 2026-04-17 11:56:41 -07:00
Joe Li
f1b8940f5e Merge branch 'master' into fix-echart-save-issue 2026-04-15 22:03:56 -07:00
Joe Li
9fcf6d8134 Merge branch 'master' into fix-echart-save-issue 2026-03-27 09:14:51 -07:00
Joe Li
5a194fdaf1 fix: align matrixify test helpers with ControlStateMapping type
The previous commit replaced `controls: any` with `ControlStateMapping`
in isMatrixifyVisible but left test helpers returning incompatible types,
breaking the tsc step in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 13:40:21 -07:00
Joe Li
570c05a7e5 fix: replace controls: any with ControlStateMapping in isMatrixifyVisible
Address PR review feedback from @gabotorresruiz — use the proper
ControlStateMapping type instead of `any` for the controls parameter,
matching the pattern already used in customControls.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:25:30 -07:00
Joe Li
c73f75eec2 chore: prettier 2026-03-24 10:25:30 -07:00
Joe Li
dea5e3197f fix(explore): migrate old per-axis matrixify flags + normalize dashboard path
- Detect pre-revamp per-axis enable flags (matrixify_enable_vertical_layout /
  matrixify_enable_horizontal_layout) and migrate to matrixify_enable: true,
  preserving modes only for enabled axes
- Export handleDeprecatedControls and call in hydrateExplore to normalize
  form_data before Redux state storage (consistency with getControlsState)
- applyDefaultFormData now runs handleDeprecatedControls on a copy, protecting
  dashboard hydration path from stale matrixify modes
- 15 new tests: old per-axis flag scenarios, dashboard hydration, direct
  handleDeprecatedControls verification

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:25:30 -07:00
Joe Li
78a2752480 fix(explore): document downstream consumers in matrixify mode migration comment
The handleDeprecatedControls() migration comment now explains *why* stale
matrixify modes must be normalized: 4 UI consumers (ExploreChartPanel,
ChartContextMenu, DrillBySubmenu, ChartRenderer) infer "matrixify is active"
from mode values alone without checking matrixify_enable. Test comments
updated to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:25:30 -07:00
Joe Li
ae61a0cdfc fix(explore): add matrixify_enable guard to prevent stale validators on pre-revamp charts
PR #38519 dropped the matrixify_enable check from isMatrixifyVisible(),
causing hidden validators to fire on charts saved before the revamp.
Old charts have stale matrixify_mode defaults (e.g. 'dimensions') that
make isMatrixifyVisible() return true, injecting validators that silently
block save.

- Add matrixify_enable !== true guard as first check in isMatrixifyVisible()
- Add form_data migration in handleDeprecatedControls() to reset stale
  matrixify modes when matrixify_enable key is absent (legacy charts)
- Export isMatrixifyVisible for direct unit testing
- Add 25 tests covering guard, validator injection, migration, and
  dashboard hydration paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:25:30 -07:00
6 changed files with 657 additions and 61 deletions

View File

@@ -18,6 +18,7 @@
*/
import { isMatrixifyVisible } from './matrixifyControls';
import type { ControlStateMapping } from '../types';
/**
* Helper to build a controls object matching the shape used by
@@ -25,7 +26,7 @@ import { isMatrixifyVisible } from './matrixifyControls';
*/
function makeControls(
overrides: Record<string, unknown> = {},
): Record<string, { value: unknown }> {
): ControlStateMapping {
const defaults: Record<string, unknown> = {
matrixify_enable: false,
matrixify_mode_rows: 'disabled',
@@ -36,7 +37,7 @@ function makeControls(
const merged = { ...defaults, ...overrides };
return Object.fromEntries(
Object.entries(merged).map(([k, v]) => [k, { value: v }]),
);
) as ControlStateMapping;
}
// ── matrixify_enable guard ──────────────────────────────────────────

View File

@@ -20,7 +20,7 @@
import { t } from '@apache-superset/core/translation';
import { validateNonEmpty } from '@superset-ui/core';
import { SharedControlConfig } from '../types';
import { ControlStateMapping, SharedControlConfig } from '../types';
import { dndAdhocMetricControl } from './dndControls';
import { defineSavedMetrics } from '../utils';
@@ -29,9 +29,12 @@ import { defineSavedMetrics } from '../utils';
* Controls for transforming charts into matrix/grid layouts
*/
// Utility function to check if matrixify controls should be visible
// Utility function to check if matrixify controls should be visible.
// Controls both visibility callbacks and validator injection via mapStateToProps.
// The matrixify_enable guard prevents hidden validators from firing on
// pre-revamp charts with stale matrixify_mode defaults (fix for #38519).
const isMatrixifyVisible = (
controls: any,
controls: ControlStateMapping | undefined,
axis: 'rows' | 'columns',
mode?: 'metrics' | 'dimensions',
selectionMode?: 'members' | 'topn' | 'all',

View File

@@ -0,0 +1,238 @@
/**
* 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.
*/
/**
* Tests for the matrixify_enable guard in isMatrixifyVisible() and
* validator injection via mapStateToProps on real matrixify control definitions.
*
* These are TDD tests for the fix to apache/superset#38519 regression:
* isMatrixifyVisible() must check matrixify_enable before evaluating mode,
* otherwise pre-revamp charts with stale matrixify_mode defaults trigger
* hidden validators that block save.
*/
import {
matrixifyControls,
isMatrixifyVisible,
} from '../../src/shared-controls/matrixifyControls';
import type { ControlPanelState, ControlStateMapping } from '../../src/types';
// Helper: build a minimal controls object for ControlPanelState
const buildControls = (
overrides: Record<string, any> = {},
): ControlStateMapping => {
const controls: Record<string, { value: any }> = {};
Object.entries(overrides).forEach(([key, value]) => {
controls[key] = { value };
});
return controls as ControlStateMapping;
};
// Helper: build a minimal ControlPanelState for mapStateToProps.
// Only provides fields that isMatrixifyVisible and mapStateToProps actually read.
const buildState = (
controlValues: Record<string, any> = {},
formData: Record<string, any> = {},
) =>
({
controls: buildControls(controlValues),
datasource: { columns: [], type: 'table' },
form_data: formData,
common: {},
metadata: {},
slice: { slice_id: 0 },
}) as unknown as ControlPanelState;
// ============================================================
// Validator injection tests via real mapStateToProps (rows)
// ============================================================
// --- matrixify_dimension_rows ---
test('matrixify_dimension_rows: validators empty when matrixify_enable is falsy', () => {
const control = matrixifyControls.matrixify_dimension_rows;
const state = buildState(
{
matrixify_enable: undefined,
matrixify_mode_rows: 'dimensions',
matrixify_dimension_selection_mode_rows: 'members',
},
{ matrixify_mode_rows: 'dimensions' },
);
const result = control.mapStateToProps!(state, {} as any);
expect(result.validators).toEqual([]);
});
test('matrixify_dimension_rows: validators present when matrixify_enable is true', () => {
const control = matrixifyControls.matrixify_dimension_rows;
const state = buildState(
{
matrixify_enable: true,
matrixify_mode_rows: 'dimensions',
matrixify_dimension_selection_mode_rows: 'members',
},
{ matrixify_mode_rows: 'dimensions' },
);
const result = control.mapStateToProps!(state, {} as any);
expect(result.validators.length).toBeGreaterThan(0);
});
// --- matrixify_topn_value_rows ---
test('matrixify_topn_value_rows: validators empty when matrixify_enable is falsy', () => {
const control = matrixifyControls.matrixify_topn_value_rows;
const state = buildState(
{
matrixify_enable: undefined,
matrixify_mode_rows: 'dimensions',
matrixify_dimension_selection_mode_rows: 'topn',
},
{ matrixify_mode_rows: 'dimensions' },
);
const result = control.mapStateToProps!(state, {} as any);
expect(result.validators).toEqual([]);
});
test('matrixify_topn_value_rows: validators present when matrixify_enable is true', () => {
const control = matrixifyControls.matrixify_topn_value_rows;
const state = buildState(
{
matrixify_enable: true,
matrixify_mode_rows: 'dimensions',
matrixify_dimension_selection_mode_rows: 'topn',
},
{ matrixify_mode_rows: 'dimensions' },
);
const result = control.mapStateToProps!(state, {} as any);
expect(result.validators.length).toBeGreaterThan(0);
});
// --- matrixify_topn_metric_rows ---
test('matrixify_topn_metric_rows: validators empty when matrixify_enable is falsy', () => {
const control = matrixifyControls.matrixify_topn_metric_rows;
const state = buildState(
{
matrixify_enable: undefined,
matrixify_mode_rows: 'dimensions',
matrixify_dimension_selection_mode_rows: 'topn',
},
{ matrixify_mode_rows: 'dimensions' },
);
const result = control.mapStateToProps!(state, {} as any);
expect(result.validators).toEqual([]);
});
test('matrixify_topn_metric_rows: validators present when matrixify_enable is true', () => {
const control = matrixifyControls.matrixify_topn_metric_rows;
const state = buildState(
{
matrixify_enable: true,
matrixify_mode_rows: 'dimensions',
matrixify_dimension_selection_mode_rows: 'topn',
},
{ matrixify_mode_rows: 'dimensions' },
);
const result = control.mapStateToProps!(state, {} as any);
expect(result.validators.length).toBeGreaterThan(0);
});
// ============================================================
// Validator injection tests via real mapStateToProps (columns)
// ============================================================
test('matrixify_dimension_columns: validators empty when matrixify_enable is falsy', () => {
const control = matrixifyControls.matrixify_dimension_columns;
const state = buildState(
{
matrixify_enable: undefined,
matrixify_mode_columns: 'dimensions',
matrixify_dimension_selection_mode_columns: 'members',
},
{ matrixify_mode_columns: 'dimensions' },
);
const result = control.mapStateToProps!(state, {} as any);
expect(result.validators).toEqual([]);
});
test('matrixify_dimension_columns: validators present when matrixify_enable is true', () => {
const control = matrixifyControls.matrixify_dimension_columns;
const state = buildState(
{
matrixify_enable: true,
matrixify_mode_columns: 'dimensions',
matrixify_dimension_selection_mode_columns: 'members',
},
{ matrixify_mode_columns: 'dimensions' },
);
const result = control.mapStateToProps!(state, {} as any);
expect(result.validators.length).toBeGreaterThan(0);
});
// ============================================================
// Direct isMatrixifyVisible guard tests
// ============================================================
test.each([
['undefined', undefined],
['null', null],
['false', false],
['0', 0],
])(
'isMatrixifyVisible returns false when matrixify_enable is %s',
(_, value) => {
const controls = buildControls({
matrixify_enable: value,
matrixify_mode_rows: 'dimensions',
});
expect(isMatrixifyVisible(controls, 'rows')).toBe(false);
},
);
test('isMatrixifyVisible returns true when matrixify_enable is true and mode matches', () => {
const controls = buildControls({
matrixify_enable: true,
matrixify_mode_rows: 'dimensions',
});
expect(isMatrixifyVisible(controls, 'rows', 'dimensions')).toBe(true);
});
test('isMatrixifyVisible returns false when matrixify_enable is true but mode is disabled', () => {
const controls = buildControls({
matrixify_enable: true,
matrixify_mode_rows: 'disabled',
});
expect(isMatrixifyVisible(controls, 'rows')).toBe(false);
});
test('isMatrixifyVisible returns true when matrixify_enable is true and any non-disabled mode (no mode filter)', () => {
const controls = buildControls({
matrixify_enable: true,
matrixify_mode_columns: 'metrics',
});
expect(isMatrixifyVisible(controls, 'columns')).toBe(true);
});

View File

@@ -24,7 +24,7 @@ import {
ExplorePageState,
} from 'src/explore/types';
import { getChartKey } from 'src/explore/exploreUtils';
import { getControlsState } from 'src/explore/store';
import { getControlsState, handleDeprecatedControls } from 'src/explore/store';
import { Dispatch } from 'redux';
import {
Currency,
@@ -116,6 +116,12 @@ export const hydrateExplore =
]),
);
// Normalize deprecated controls (e.g., migrate old per-axis matrixify
// flags to matrixify_enable) before form_data is stored in Redux state.
// getControlsState also calls this on its own copy, but state.form_data
// must reflect the same migration so the two stay consistent.
handleDeprecatedControls(initialFormData);
const initialExploreState = {
form_data: initialFormData,
slice: initialSlice,

View File

@@ -17,55 +17,359 @@
* under the License.
*/
import { getChartControlPanelRegistry } from '@superset-ui/core';
import { applyDefaultFormData } from 'src/explore/store';
import {
applyDefaultFormData,
getControlsState,
handleDeprecatedControls,
} from 'src/explore/store';
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('store', () => {
beforeAll(() => {
getChartControlPanelRegistry().registerValue('test-chart', {
controlPanelSections: [
{
label: 'Test section',
expanded: true,
controlSetRows: [['row_limit']],
},
],
});
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).featureFlags = {};
afterAll(() => {
getChartControlPanelRegistry().remove('test-chart');
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('applyDefaultFormData', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).featureFlags = {};
test('applies default to formData if the key is missing', () => {
const inputFormData = {
datasource: '11_table',
viz_type: 'test-chart',
};
let outputFormData = applyDefaultFormData(inputFormData);
expect(outputFormData.row_limit).toEqual(10000);
const inputWithRowLimit = {
...inputFormData,
row_limit: 888,
};
outputFormData = applyDefaultFormData(inputWithRowLimit);
expect(outputFormData.row_limit).toEqual(888);
});
test('keeps null if key is defined with null', () => {
const inputFormData = {
datasource: '11_table',
viz_type: 'test-chart',
row_limit: null,
};
const outputFormData = applyDefaultFormData(inputFormData);
expect(outputFormData.row_limit).toBe(null);
});
beforeAll(() => {
getChartControlPanelRegistry().registerValue('test-chart', {
controlPanelSections: [
{
label: 'Test section',
expanded: true,
controlSetRows: [['row_limit']],
},
],
});
});
afterAll(() => {
getChartControlPanelRegistry().remove('test-chart');
});
// Helper: build ExploreState for getControlsState
const buildExploreState = (controlOverrides: Record<string, any> = {}) => ({
datasource: { type: 'table' },
controls: Object.fromEntries(
Object.entries(controlOverrides).map(([k, v]) => [k, { value: v }]),
),
});
// ============================================================
// Existing applyDefaultFormData tests
// ============================================================
test('applyDefaultFormData applies default to formData if the key is missing', () => {
const inputFormData = {
datasource: '11_table',
viz_type: 'test-chart',
};
let outputFormData = applyDefaultFormData(inputFormData);
expect(outputFormData.row_limit).toEqual(10000);
const inputWithRowLimit = {
...inputFormData,
row_limit: 888,
};
outputFormData = applyDefaultFormData(inputWithRowLimit);
expect(outputFormData.row_limit).toEqual(888);
});
test('applyDefaultFormData keeps null if key is defined with null', () => {
const inputFormData = {
datasource: '11_table',
viz_type: 'test-chart',
row_limit: null,
};
const outputFormData = applyDefaultFormData(inputFormData);
expect(outputFormData.row_limit).toBe(null);
});
// ============================================================
// Migration tests: handleDeprecatedControls normalizes stale matrixify modes
// (fix for apache/superset#38519 regression — guards validators AND
// downstream UI consumers that infer matrixify state from mode values)
// ============================================================
test('getControlsState resets stale matrixify_mode_rows to disabled when matrixify_enable key absent', () => {
const state = buildExploreState();
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_mode_rows: 'dimensions', // stale pre-revamp default
};
const result = getControlsState(state as any, formData as any);
const modeControl = result.matrixify_mode_rows as any;
expect(modeControl?.value).toBe('disabled');
});
test('getControlsState resets stale matrixify_mode_columns to disabled when matrixify_enable key absent', () => {
const state = buildExploreState();
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_mode_columns: 'metrics', // stale pre-revamp default
};
const result = getControlsState(state as any, formData as any);
const modeControl = result.matrixify_mode_columns as any;
expect(modeControl?.value).toBe('disabled');
});
test('getControlsState preserves matrixify mode values when matrixify_enable is true', () => {
const state = buildExploreState();
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_enable: true,
matrixify_mode_rows: 'dimensions',
};
const result = getControlsState(state as any, formData as any);
const modeControl = result.matrixify_mode_rows as any;
expect(modeControl?.value).toBe('dimensions');
});
test('getControlsState preserves matrixify mode values when matrixify_enable is explicitly false', () => {
const state = buildExploreState();
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_enable: false,
matrixify_mode_rows: 'dimensions',
};
const result = getControlsState(state as any, formData as any);
const modeControl = result.matrixify_mode_rows as any;
// matrixify_enable key IS present (just false) — migration does NOT fire
expect(modeControl?.value).toBe('dimensions');
});
test('getControlsState is idempotent when matrixify modes already disabled', () => {
const state = buildExploreState();
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_mode_rows: 'disabled',
matrixify_mode_columns: 'disabled',
};
const result = getControlsState(state as any, formData as any);
expect((result.matrixify_mode_rows as any)?.value).toBe('disabled');
expect((result.matrixify_mode_columns as any)?.value).toBe('disabled');
});
test('getControlsState handles form_data with no matrixify keys', () => {
const state = buildExploreState();
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
};
const result = getControlsState(state as any, formData as any);
// Controls should get their defaults — matrixify_mode defaults to 'disabled'
expect((result.matrixify_mode_rows as any)?.value).toBe('disabled');
expect((result.matrixify_mode_columns as any)?.value).toBe('disabled');
});
test('getControlsState round-trip: pre-revamp form_data produces no matrixify validation errors', () => {
// Simulate a chart saved before #38519 with stale matrixify defaults
// Empty controls: on real first-load hydration, no pre-existing controls exist
const state = buildExploreState();
const preRevampFormData = {
datasource: '1__table',
viz_type: 'test-chart',
// Stale old defaults — no matrixify_enable key (legacy chart)
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
};
const result = getControlsState(state as any, preRevampFormData as any);
// Every matrixify control should have zero validation errors
const matrixifyControlEntries = Object.entries(result).filter(([name]) =>
name.startsWith('matrixify_'),
);
const controlsWithErrors = matrixifyControlEntries.filter(
([, control]) => (control as any)?.validationErrors?.length > 0,
);
expect(controlsWithErrors).toEqual([]);
});
// ============================================================
// Dashboard hydration: applyDefaultFormData with stale form_data
// ============================================================
test('applyDefaultFormData normalizes stale matrixify modes for legacy charts', () => {
// Dashboard hydration now runs handleDeprecatedControls too, so stale
// matrixify modes from pre-revamp charts are normalized to 'disabled'.
// This protects downstream consumers (ChartContextMenu, DrillBySubmenu,
// ChartRenderer) that infer "matrixify is active" from mode values alone.
const preRevampFormData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
// No matrixify_enable key — legacy chart that never used matrixify
};
const outputFormData = applyDefaultFormData(preRevampFormData as any);
// Stale values are now normalized to 'disabled'
expect(outputFormData.matrixify_mode_rows).toBe('disabled');
expect(outputFormData.matrixify_mode_columns).toBe('disabled');
expect(outputFormData.matrixify_enable).toBe(false);
});
// ============================================================
// P1: Pre-revamp charts that actually used matrixify via old per-axis flags
// (matrixify_enable_vertical_layout / matrixify_enable_horizontal_layout)
// ============================================================
test('getControlsState preserves modes and sets matrixify_enable when old vertical flag is true', () => {
const state = buildExploreState();
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
};
const result = getControlsState(state as any, formData as any);
// Vertical layout was enabled — rows mode preserved, matrixify_enable migrated
expect((result.matrixify_mode_rows as any)?.value).toBe('dimensions');
expect((result.matrixify_enable as any)?.value).toBe(true);
// Horizontal layout was NOT enabled — columns mode reset
expect((result.matrixify_mode_columns as any)?.value).toBe('disabled');
});
test('getControlsState preserves modes and sets matrixify_enable when old horizontal flag is true', () => {
const state = buildExploreState();
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_enable_horizontal_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
};
const result = getControlsState(state as any, formData as any);
// Horizontal layout was enabled — columns mode preserved, matrixify_enable migrated
expect((result.matrixify_mode_columns as any)?.value).toBe('metrics');
expect((result.matrixify_enable as any)?.value).toBe(true);
// Vertical layout was NOT enabled — rows mode reset
expect((result.matrixify_mode_rows as any)?.value).toBe('disabled');
});
test('getControlsState preserves both modes when both old per-axis flags are true', () => {
const state = buildExploreState();
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
};
const result = getControlsState(state as any, formData as any);
expect((result.matrixify_mode_rows as any)?.value).toBe('dimensions');
expect((result.matrixify_mode_columns as any)?.value).toBe('metrics');
expect((result.matrixify_enable as any)?.value).toBe(true);
});
test('getControlsState resets modes when old per-axis flags are explicitly false', () => {
const state = buildExploreState();
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_enable_vertical_layout: false,
matrixify_enable_horizontal_layout: false,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
};
const result = getControlsState(state as any, formData as any);
// Old flags present but false — chart never used matrixify, reset stale modes
expect((result.matrixify_mode_rows as any)?.value).toBe('disabled');
expect((result.matrixify_mode_columns as any)?.value).toBe('disabled');
});
// ============================================================
// P2: Dashboard hydration (applyDefaultFormData) with old per-axis flags
// ============================================================
test('applyDefaultFormData preserves modes when old vertical flag is true', () => {
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
};
const outputFormData = applyDefaultFormData(formData as any);
expect(outputFormData.matrixify_mode_rows).toBe('dimensions');
expect(outputFormData.matrixify_enable).toBe(true);
// Horizontal not enabled — columns reset
expect(outputFormData.matrixify_mode_columns).toBe('disabled');
});
test('applyDefaultFormData preserves modes when both old flags are true', () => {
const formData = {
datasource: '1__table',
viz_type: 'test-chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
};
const outputFormData = applyDefaultFormData(formData as any);
expect(outputFormData.matrixify_mode_rows).toBe('dimensions');
expect(outputFormData.matrixify_mode_columns).toBe('metrics');
expect(outputFormData.matrixify_enable).toBe(true);
});
// ============================================================
// Direct handleDeprecatedControls tests: verify form_data mutation
// so callers (hydrateExplore) can propagate migrated fields into state
// ============================================================
test('handleDeprecatedControls sets matrixify_enable on form_data when old vertical flag is true', () => {
const formData: any = {
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
};
handleDeprecatedControls(formData);
expect(formData.matrixify_enable).toBe(true);
expect(formData.matrixify_mode_rows).toBe('dimensions');
// Horizontal not enabled — columns reset
expect(formData.matrixify_mode_columns).toBe('disabled');
});
test('handleDeprecatedControls resets modes when no matrixify_enable and no old flags', () => {
const formData: any = {
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
};
handleDeprecatedControls(formData);
expect(formData.matrixify_enable).toBeUndefined();
expect(formData.matrixify_mode_rows).toBe('disabled');
expect(formData.matrixify_mode_columns).toBe('disabled');
});
test('handleDeprecatedControls is idempotent — no-op when matrixify_enable already present', () => {
const formData: any = {
matrixify_enable: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'metrics',
};
handleDeprecatedControls(formData);
// No mutation — matrixify_enable key is present
expect(formData.matrixify_enable).toBe(true);
expect(formData.matrixify_mode_rows).toBe('dimensions');
expect(formData.matrixify_mode_columns).toBe('metrics');
});

View File

@@ -41,9 +41,16 @@ type FormData = QueryFormData & {
y_axis_zero?: boolean;
y_axis_bounds?: [number | null, number | null];
datasource?: string;
matrixify_enable?: boolean;
matrixify_mode_rows?: string;
matrixify_mode_columns?: string;
// Pre-revamp per-axis enable flags (removed in #38519, may still exist in
// persisted form_data for charts that actually used matrixify)
matrixify_enable_vertical_layout?: boolean;
matrixify_enable_horizontal_layout?: boolean;
};
function handleDeprecatedControls(formData: FormData): void {
export function handleDeprecatedControls(formData: FormData): void {
// Reaffectation / handling of deprecated controls
/* eslint-disable no-param-reassign */
@@ -51,6 +58,37 @@ function handleDeprecatedControls(formData: FormData): void {
if (formData.y_axis_zero) {
formData.y_axis_bounds = [0, null];
}
// #38519: migrate pre-revamp matrixify controls to the new single-toggle
// system. Before the revamp, per-axis enable flags
// (matrixify_enable_vertical_layout / matrixify_enable_horizontal_layout)
// gated visibility, and matrixify_mode_rows/columns defaulted to
// non-disabled values ('dimensions'/'metrics'). The revamp replaced those
// with a single matrixify_enable toggle and mode default 'disabled'.
//
// Charts that actually used matrixify pre-revamp have the old per-axis
// flags set to true — we must preserve their modes and set
// matrixify_enable: true. Charts that never used matrixify (or predate it)
// need stale mode defaults reset to 'disabled' because 4 downstream UI
// consumers (ExploreChartPanel, ChartContextMenu, DrillBySubmenu,
// ChartRenderer) infer "matrixify is active" from mode values alone.
if (!('matrixify_enable' in formData)) {
const hadVerticalLayout =
formData.matrixify_enable_vertical_layout === true;
const hadHorizontalLayout =
formData.matrixify_enable_horizontal_layout === true;
if (hadVerticalLayout || hadHorizontalLayout) {
// Pre-revamp chart that genuinely used matrixify — migrate to new flag
formData.matrixify_enable = true;
if (!hadVerticalLayout) formData.matrixify_mode_rows = 'disabled';
if (!hadHorizontalLayout) formData.matrixify_mode_columns = 'disabled';
} else {
// Never used matrixify — reset stale defaults
formData.matrixify_mode_rows = 'disabled';
formData.matrixify_mode_columns = 'disabled';
}
}
}
export function getControlsState(
@@ -89,25 +127,31 @@ export function getControlsState(
export function applyDefaultFormData(
inputFormData: FormData,
): Record<string, unknown> {
const datasourceType = inputFormData.datasource?.split('__')[1] ?? '';
const vizType = inputFormData.viz_type;
// Normalize deprecated controls before building control state — ensures
// stale matrixify modes are cleaned on the dashboard hydration path too,
// not just the explore path (getControlsState).
const cleanedFormData = { ...inputFormData };
handleDeprecatedControls(cleanedFormData);
const datasourceType = cleanedFormData.datasource?.split('__')[1] ?? '';
const vizType = cleanedFormData.viz_type;
const controlsState = getAllControlsState(
vizType,
datasourceType as DatasourceType,
null,
inputFormData,
cleanedFormData,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const controlFormData = getFormDataFromControls(controlsState as any);
const formData: Record<string, unknown> = {};
Object.keys(controlsState)
.concat(Object.keys(inputFormData))
.concat(Object.keys(cleanedFormData))
.forEach(controlName => {
if (inputFormData[controlName as keyof FormData] === undefined) {
if (cleanedFormData[controlName as keyof FormData] === undefined) {
formData[controlName] = controlFormData[controlName];
} else {
formData[controlName] = inputFormData[controlName as keyof FormData];
formData[controlName] = cleanedFormData[controlName as keyof FormData];
}
});