Compare commits

...

3 Commits

Author SHA1 Message Date
rusackas
6962044df5 fix: address bot review findings on refresh_frequency JSON sync
- Validate the JSON editor's refresh_frequency (not just the dropdown
  state) so a value edited directly in Advanced JSON still enforces
  SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT.
- Don't overwrite the Advanced JSON editor with a one-field object when
  the dropdown changes while the editor holds invalid/in-progress JSON.
- Keep shared_label_colors, map_label_colors, and color_scheme_domain
  resetting to their default when absent from the payload, since the
  Properties modal never sends them and preserving stale values could
  leave outdated color mappings after a color scheme change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 07:54:44 -07:00
Evan
16e61fb856 test: mock SupersetClient.put so the save path reaches onSubmit
The refresh_frequency preservation test clicks Save with onlyApply: false,
which PUTs to the dashboard API before calling onSubmit. Without a mocked
put the request rejects and onSubmit is never invoked, failing the test in
CI. Mirrors the mock used by the existing onlyApply:false submit test.
Adds an aria-label to the mocked textarea to satisfy jsx-a11y.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 02:57:44 -07:00
Evan Rusackas
47fa9705e4 fix(dashboard): keep refresh_frequency set via the Advanced JSON editor
The Dashboard Properties modal seeds a separate refreshFrequency state from the
Refresh dropdown and, on save, unconditionally overwrote
json_metadata.refresh_frequency with it, so a value typed directly into the
Advanced JSON editor was clobbered by the (stale) dropdown value and reset to 0.
This mirrors how color_scheme is already handled: the JSON editor value now
takes precedence on save, and a dropdown change syncs back into the JSON so the
two sources can't diverge.

Also hardens DashboardDAO.set_dash_metadata so metadata fields absent from the
incoming payload are preserved instead of reset to their defaults (the pattern
dosu flagged on the issue), preventing partial updates from wiping unrelated
fields.

Fixes #42116

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 02:57:41 -07:00
4 changed files with 177 additions and 15 deletions

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import {
fireEvent,
render,
screen,
userEvent,
@@ -41,6 +42,30 @@ jest.mock('@superset-ui/core', () => ({
})),
}));
// Mock the Advanced JSON editor (Ace-based, not drivable in jsdom) with a plain
// textarea so a direct JSON edit can be simulated. Keeps the "JSON Metadata"
// label so the existing "should open advance" test still passes.
jest.mock('./sections/AdvancedSection', () => ({
__esModule: true,
default: ({
jsonMetadata,
onJsonMetadataChange,
}: {
jsonMetadata: string;
onJsonMetadataChange: (value: string) => void;
}) => (
<div>
<span>JSON Metadata</span>
<textarea
aria-label="JSON metadata editor"
data-test="mock-json-editor"
value={jsonMetadata}
onChange={e => onJsonMetadataChange(e.target.value)}
/>
</div>
),
}));
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
const spyColorSchemeSelect = jest.spyOn(ColorSchemeSelect, 'default');
@@ -259,6 +284,56 @@ describe('PropertiesModal', () => {
});
});
test('preserves a refresh_frequency edited in the JSON editor on save (#42116)', async () => {
// Save (onlyApply: false) PUTs to the API before calling onSubmit, so the
// request must be mocked or onSubmit is never reached.
const put = jest.spyOn(SupersetCore.SupersetClient, 'put');
put.mockResolvedValue({
json: {
result: {
dashboard_title: 'dashboard_title',
slug: 'slug',
json_metadata: 'json_metadata',
editors: 'editors',
},
},
} as any);
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
const propsWithDashboardInfo = {
...props,
dashboardInfo: {
...dashboardInfo,
json_metadata: mockedJsonMetadata,
},
};
render(<PropertiesModal {...propsWithDashboardInfo} />, {
useRedux: true,
});
await screen.findByTestId('dashboard-edit-properties-form');
// Expand the Advanced settings panel so the (mocked) JSON editor mounts.
const advancedHeader = screen
.getByText('Advanced settings')
.closest('.ant-collapse-header');
await userEvent.click(advancedHeader!);
// Edit refresh_frequency directly in the JSON editor without touching the
// Refresh dropdown (the reproduction of #42116).
const editor = await screen.findByTestId('mock-json-editor');
fireEvent.change(editor, {
target: { value: JSON.stringify({ refresh_frequency: 30 }) },
});
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledTimes(1);
});
const submitted = JSON.parse(props.onSubmit.mock.calls[0][0].jsonMetadata);
expect(submitted.refresh_frequency).toBe(30);
});
test('should close modal', async () => {
mockedIsFeatureEnabled.mockImplementation((flag: any) => {
if (flag === FeatureFlag.TaggingSystem) return true;

View File

@@ -238,17 +238,19 @@ const PropertiesModal = ({
}, handleErrorResponse);
}, [dashboardId, handleDashboardData]);
const getJsonMetadata = () => {
// Returns undefined (rather than {}) when the editor holds invalid JSON,
// so callers can distinguish "empty/no metadata yet" from "user is
// mid-edit with unparsable text" and avoid clobbering the latter.
const parseJsonMetadata = () => {
try {
const jsonMetadataObj = jsonMetadata?.length
? JSON.parse(jsonMetadata)
: {};
return jsonMetadataObj;
return jsonMetadata?.length ? JSON.parse(jsonMetadata) : {};
} catch (_) {
return {};
return undefined;
}
};
const getJsonMetadata = () => parseJsonMetadata() ?? {};
const handleOnChangeEditors = (values: SubjectPickerValue[]) => {
setEditors(mapPickerValuesToSubjects(values));
};
@@ -343,7 +345,12 @@ const PropertiesModal = ({
? resettableCustomLabels
: false;
const jsonMetadataObj = getJsonMetadata();
jsonMetadataObj.refresh_frequency = refreshFrequency;
// A refresh_frequency edited directly in the Advanced JSON editor takes
// precedence over the Refresh dropdown state, mirroring how color_scheme is
// handled above. Nullish coalescing preserves an explicit 0 ("Don't
// refresh") rather than falling through to the dropdown value (#42116).
jsonMetadataObj.refresh_frequency =
jsonMetadataObj.refresh_frequency ?? refreshFrequency;
jsonMetadataObj.show_chart_timestamps = Boolean(showChartTimestamps);
const customLabelColors = jsonMetadataObj.label_colors || {};
const updatedDashboardMetadata = {
@@ -537,8 +544,19 @@ const PropertiesModal = ({
// Section handlers for extracted components
const handleThemeChange = (value: any) => setSelectedThemeId(value || null);
const handleRefreshFrequencyChange = (value: number) =>
const handleRefreshFrequencyChange = (value: number) => {
setRefreshFrequency(value);
// Keep the Advanced JSON editor in sync with the dropdown so the two
// sources can't diverge, mirroring onColorSchemeChange (#42116). Skip
// this while the editor holds invalid/in-progress JSON so we don't
// clobber the user's unfinished edit with a one-field object.
const jsonMetadataObj = parseJsonMetadata();
if (!jsonMetadataObj) {
return;
}
jsonMetadataObj.refresh_frequency = value;
setJsonMetadata(jsonStringify(jsonMetadataObj));
};
// Helper function for styling section
const hasCustomLabelsColor = !!Object.keys(
@@ -580,7 +598,15 @@ const PropertiesModal = ({
const refreshLimit =
dashboardInfo?.common?.conf
?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT;
return validateRefreshFrequency(refreshFrequency, refreshLimit);
// A refresh_frequency edited directly in the Advanced JSON editor
// takes precedence over the dropdown when saving (see onFinish),
// so validate that effective value rather than the dropdown state.
const effectiveRefreshFrequency =
getJsonMetadata()?.refresh_frequency ?? refreshFrequency;
return validateRefreshFrequency(
effectiveRefreshFrequency,
refreshLimit,
);
},
},
{

View File

@@ -396,15 +396,36 @@ class DashboardDAO(BaseDAO[Dashboard]):
else:
md["color_namespace"] = data.get("color_namespace")
md["expanded_slices"] = data.get("expanded_slices", {})
if "refresh_frequency" in data:
md["refresh_frequency"] = data["refresh_frequency"]
md["color_scheme"] = data.get("color_scheme", "")
md["label_colors"] = data.get("label_colors", {})
# Only overwrite these metadata fields when the caller explicitly sends
# them. Previously each used ``data.get(key, default)``, which reset a
# value to its default whenever it was absent from the payload -- e.g. a
# ``refresh_frequency`` set directly in the Advanced JSON editor got
# wiped on save. ``setdefault`` still seeds a default for brand-new
# dashboards that have never had the key, keeping the shape stable
# without clobbering existing values (#42116).
metadata_defaults: dict[str, Any] = {
"expanded_slices": {},
"refresh_frequency": 0,
"color_scheme": "",
"label_colors": {},
"cross_filters_enabled": True,
}
for key, default_value in metadata_defaults.items():
if key in data:
md[key] = data[key]
else:
md.setdefault(key, default_value)
# These are derived from ``color_scheme``/``label_colors`` on the
# frontend (see ``applyDashboardLabelsColorOnLoad``) and are always
# omitted from the Properties modal's payload, so they must keep
# resetting to their default when absent rather than being preserved
# -- otherwise a color scheme change made through that modal leaves
# stale label-to-color mappings in place instead of triggering
# recomputation on next load.
md["shared_label_colors"] = data.get("shared_label_colors", [])
md["map_label_colors"] = data.get("map_label_colors", {})
md["color_scheme_domain"] = data.get("color_scheme_domain", [])
md["cross_filters_enabled"] = data.get("cross_filters_enabled", True)
dashboard.json_metadata = json.dumps(md)
@staticmethod

View File

@@ -73,6 +73,46 @@ class TestDashboardDAO(SupersetTestCase):
DashboardDAO.set_dash_metadata(dashboard, original_data)
db.session.commit()
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@patch("superset.utils.core.g")
@patch("superset.security.manager.g")
def test_set_dash_metadata_preserves_unsent_fields(self, mock_sm_g, mock_g):
"""
set_dash_metadata must not reset metadata fields that are absent from the
incoming payload, such as a ``refresh_frequency`` edited directly in the
Advanced JSON editor (#42116). Fields that are present still override.
"""
mock_g.user = mock_sm_g.user = security_manager.find_user("admin")
with self.client.application.test_request_context():
dashboard = (
db.session.query(Dashboard).filter_by(slug="world_health").first()
)
original_json_metadata = dashboard.json_metadata
try:
# Seed an existing refresh_frequency in the stored metadata.
metadata = json.loads(dashboard.json_metadata or "{}")
metadata["refresh_frequency"] = 60
dashboard.json_metadata = json.dumps(metadata)
db.session.commit()
# Payload omits refresh_frequency: it must be preserved, not
# reset to 0.
DashboardDAO.set_dash_metadata(
dashboard, {"color_scheme": "d3Category10"}
)
db.session.commit()
saved = json.loads(dashboard.json_metadata)
assert saved["refresh_frequency"] == 60
assert saved["color_scheme"] == "d3Category10"
# An explicitly-sent value still overrides.
DashboardDAO.set_dash_metadata(dashboard, {"refresh_frequency": 30})
db.session.commit()
assert json.loads(dashboard.json_metadata)["refresh_frequency"] == 30
finally:
dashboard.json_metadata = original_json_metadata
db.session.commit()
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@patch("superset.daos.dashboard.g")
@patch("superset.security.manager.g")