Compare commits

...

8 Commits

Author SHA1 Message Date
Elizabeth Thompson
6ee8fd1d3b fix(query_object_factory): preserve truthy-override precedence for deprecated fields
setdefault() let an already-present canonical field win over a truthy
deprecated value, reversing QueryObject._rename_deprecated_fields's
historical precedence (deprecated value always overrides when truthy).
That's what test_time_offsets_accuracy and
test_time_offsets_in_query_object_no_limit were catching in CI.

Per richardfogaca's review.
2026-07-25 18:06:45 +00:00
Elizabeth Thompson
b7765ca291 fix(schemas): rename deprecated query fields regardless of falsy values (#41263)
(cherry picked from commit 5bbab86a07)
2026-07-22 00:40:54 +00:00
Elizabeth Thompson
d96cacfb63 fix(charts): rename deprecated query object fields in schema before QueryObject construction (#41056)
(cherry picked from commit 09b4bc51a3)
2026-07-22 00:40:54 +00:00
Elizabeth Thompson
0877e66610 fix(query_object_factory): normalize deprecated query fields before constructing QueryObject (#41204)
(cherry picked from commit 7040388ad1)
2026-07-09 16:15:05 +00:00
Elizabeth Thompson
7c00f5567f fix(filters): preserve backend metric-based sorting in select filters (#35130)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-13 13:34:54 +03:00
Elizabeth Thompson
76c5b40c13 fix(embedded): fix template context for embedded views
- Use render_app_template() in EmbeddedView to provide proper template context
- Move tokens variable definition before its usage in spa.html template
- Fixes 'tokens is undefined' error in embedded dashboard views

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 18:03:23 -07:00
Elizabeth Thompson
9617b8b392 fix(modals): make Modal.confirm dialogs respect theme mode
- Add App wrapper to SupersetThemeProvider to enable theme inheritance for imperative modals
- Modal.confirm dialogs now properly inherit dark/light theme from ConfigProvider
- Simplifies theming approach by leveraging antd's built-in App context

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 17:56:32 -07:00
Elizabeth Thompson
a7b180d8dd fix(filters): remove filter bar gap, fix loading screen theming, fix filter sorting, and fix modal theming
- Remove top padding from horizontal filter bar to eliminate visual gap
- Set themed background color on loading screen to respect dark mode
- Remove fallback "Loading..." text to prevent Times New Roman display
- Use colorBgBase theme token for proper light/dark mode support
- Fix filter value sorting to preserve backend metric-based order when sortMetric is specified
- Make Modal.confirm theme-aware to respect current light/dark mode

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 17:18:53 -07:00
10 changed files with 183 additions and 21 deletions

View File

@@ -368,9 +368,13 @@ const CustomModal = ({
};
CustomModal.displayName = 'Modal';
// Theme-aware confirmation modal - now inherits theme through App wrapper in SupersetThemeProvider
const themedConfirm = (props: Parameters<typeof AntdModal.confirm>[0]) =>
AntdModal.confirm(props);
export const Modal = Object.assign(CustomModal, {
error: AntdModal.error,
warning: AntdModal.warning,
confirm: AntdModal.confirm,
confirm: themedConfirm,
useModal: AntdModal.useModal,
});

View File

@@ -19,7 +19,7 @@
/* eslint-disable react-prefer-function-component/react-prefer-function-component */
// eslint-disable-next-line no-restricted-syntax
import React from 'react';
import { theme as antdThemeImport, ConfigProvider } from 'antd';
import { theme as antdThemeImport, ConfigProvider, App } from 'antd';
// @fontsource/* v5.1+ doesn't play nice with eslint-import plugin v2.31+
/* eslint-disable import/extensions */
@@ -243,7 +243,7 @@ export class Theme {
<ThemeProvider theme={themeState.theme}>
<GlobalStyles />
<ConfigProvider theme={themeState.antdConfig}>
{children}
<App>{children}</App>
</ConfigProvider>
</ThemeProvider>
</EmotionCacheProvider>

View File

@@ -32,7 +32,7 @@ import crossFiltersSelector from './CrossFilters/selectors';
const HorizontalBar = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 2}px ${
padding: 0 ${theme.sizeUnit * 2}px ${
theme.sizeUnit * 3
}px ${theme.sizeUnit * 4}px;
background: ${theme.colorBgBase};

View File

@@ -317,13 +317,20 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const sortComparator = useCallback(
(a: LabeledValue, b: LabeledValue) => {
// When sortMetric is specified, the backend already sorted the data correctly
// Don't override the backend's metric-based sorting with frontend alphabetical sorting
if (formData.sortMetric) {
return 0; // Preserve the original order from the backend
}
// Only apply alphabetical sorting when no sortMetric is specified
const labelComparator = propertyComparator('label');
if (formData.sortAscending) {
return labelComparator(a, b);
}
return labelComparator(b, a);
},
[formData.sortAscending],
[formData.sortAscending, formData.sortMetric],
);
// Use effect for initialisation for filter plugin

View File

@@ -1380,6 +1380,22 @@ class ChartDataQueryObjectSchema(Schema):
allow_none=True,
)
@post_load
def rename_deprecated_fields(
self, data: dict[str, Any], **kwargs: Any
) -> dict[str, Any]:
_renames = (
("groupby", "columns"),
("granularity_sqla", "granularity"),
("timeseries_limit", "series_limit"),
("timeseries_limit_metric", "series_limit_metric"),
)
for old, new in _renames:
value = data.pop(old, None)
if value or value == 0:
data[new] = value
return data
class ChartDataQueryContextSchema(Schema):
query_context_factory: QueryContextFactory | None = None

View File

@@ -19,7 +19,7 @@ from __future__ import annotations
from typing import Any, cast, TYPE_CHECKING
from superset.common.chart_data import ChartDataResultType
from superset.common.query_object import QueryObject
from superset.common.query_object import DEPRECATED_FIELDS, QueryObject
from superset.common.utils.time_range_utils import get_since_until_from_time_range
from superset.constants import NO_TIME_RANGE
from superset.superset_typing import Column
@@ -66,6 +66,14 @@ class QueryObjectFactory: # pylint: disable=too-few-public-methods
processed_extras = self._process_extras(extras)
result_type = kwargs.setdefault("result_type", parent_result_type)
# Rename deprecated kwargs before any processing so that downstream code
# (time-range resolution, QueryObject) only ever sees the canonical names.
# A truthy deprecated value always overrides the canonical field, matching
# the historical QueryObject._rename_deprecated_fields precedence.
for field in DEPRECATED_FIELDS:
if old_val := kwargs.pop(field.old_name, None):
kwargs[field.new_name] = old_val
# Process row limit taking server pagination into account
row_limit = self._process_row_limit(
row_limit, result_type, server_pagination=server_pagination

View File

@@ -24,7 +24,6 @@ from flask_wtf.csrf import same_origin
from superset import event_logger, is_feature_enabled
from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.superset_typing import FlaskResponse
from superset.utils import json
from superset.views.base import BaseSupersetView, common_bootstrap_payload
@@ -76,7 +75,7 @@ class EmbeddedView(BaseSupersetView):
dashboard_version="v2",
)
bootstrap_data = {
extra_bootstrap_data = {
"config": {
"GUEST_TOKEN_HEADER_NAME": current_app.config["GUEST_TOKEN_HEADER_NAME"]
},
@@ -86,10 +85,7 @@ class EmbeddedView(BaseSupersetView):
},
}
return self.render_template(
"superset/spa.html",
return self.render_app_template(
extra_bootstrap_data=extra_bootstrap_data,
entry="embedded",
bootstrap_data=json.dumps(
bootstrap_data, default=json.pessimistic_json_iso_dttm_ser
),
)

View File

@@ -69,11 +69,11 @@
{% endblock %}
</head>
<body {% if standalone_mode %}class="standalone"{% endif %}>
{% set tokens = theme_tokens | default({}) %}
<body {% if standalone_mode %}class="standalone"{% endif %} style="margin: 0; padding: 0; background-color: {{ tokens.get('colorBgBase', '#ffffff') }};">
{% block body %}
<div id="app" data-bootstrap="{{ bootstrap_data }}">
{% set tokens = theme_tokens | default({}) %}
{% set spinner_style = "width: 70px; height: auto; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);" %}
{% if spinner_svg %}
@@ -85,14 +85,10 @@
<!-- Custom URL from theme -->
<img
src="{{ tokens.brandSpinnerUrl }}"
alt="Loading..."
alt=""
style="{{ spinner_style }}"
/>
{% else %}
<!-- Fallback: This should rarely happen with new logic -->
<div style="{{ spinner_style }}">
Loading...
</div>
{# Remove fallback text - let React app handle loading state #}
{% endif %}
</div>
{% endblock %}

View File

@@ -135,6 +135,53 @@ def test_chart_data_query_object_schema_time_grain_sqla_validation(
assert result["extras"]["time_grain_sqla"] is None
def test_chart_data_query_object_schema_deprecated_fields_renamed(
app_context: None,
) -> None:
"""Deprecated query object fields are renamed to their canonical names."""
schema = ChartDataQueryObjectSchema()
# groupby alone → becomes columns
result = schema.load({"groupby": ["country_name"]})
assert result.get("columns") == ["country_name"]
assert "groupby" not in result
# groupby overwrites columns when both are provided
result = schema.load({"groupby": ["region"], "columns": ["country_name"]})
assert result.get("columns") == ["region"]
assert "groupby" not in result
# empty groupby is discarded; existing columns is preserved
result = schema.load({"groupby": [], "columns": ["country_name"]})
assert result.get("columns") == ["country_name"]
assert "groupby" not in result
# null groupby is discarded; existing columns is preserved (allow_none=True)
result = schema.load({"groupby": None, "columns": ["country_name"]})
assert result.get("columns") == ["country_name"]
assert "groupby" not in result
# no groupby → columns passes through unchanged
result = schema.load({"columns": ["country_name"]})
assert result.get("columns") == ["country_name"]
assert "groupby" not in result
# granularity_sqla → granularity
result = schema.load({"granularity_sqla": "ds"})
assert result.get("granularity") == "ds"
assert "granularity_sqla" not in result
# timeseries_limit → series_limit
result = schema.load({"timeseries_limit": 5})
assert result.get("series_limit") == 5
assert "timeseries_limit" not in result
# timeseries_limit_metric → series_limit_metric
result = schema.load({"timeseries_limit_metric": "count"})
assert result.get("series_limit_metric") == "count"
assert "timeseries_limit_metric" not in result
@pytest.mark.parametrize(
"app",
[{"TIME_GRAIN_ADDONS": {"PT10M": "10 minutes"}}],

View File

@@ -138,3 +138,91 @@ class TestQueryObjectFactory:
**raw_query_object,
)
assert query_object.metric_names == ["SUM", "num_girls", "num_boys"]
def test_deprecated_groupby_renamed_to_columns(
self,
query_object_factory: QueryObjectFactory,
raw_query_context: dict[str, Any],
):
"""groupby in stored query_context should be silently renamed to columns."""
raw_query_object = raw_query_context["queries"][0]
raw_query_object.pop("columns", None)
raw_query_object["groupby"] = ["name", "gender"]
query_object = query_object_factory.create(
raw_query_context["result_type"], **raw_query_object
)
assert query_object.columns == ["name", "gender"]
assert not hasattr(query_object, "groupby")
def test_deprecated_groupby_overwrites_columns(
self,
query_object_factory: QueryObjectFactory,
raw_query_context: dict[str, Any],
):
"""A truthy deprecated groupby overrides an already-present columns,
matching the historical QueryObject._rename_deprecated_fields precedence."""
raw_query_object = raw_query_context["queries"][0]
raw_query_object["columns"] = ["state"]
raw_query_object["groupby"] = ["name", "gender"]
query_object = query_object_factory.create(
raw_query_context["result_type"], **raw_query_object
)
assert query_object.columns == ["name", "gender"]
def test_deprecated_groupby_empty_list_is_ignored(
self,
query_object_factory: QueryObjectFactory,
raw_query_context: dict[str, Any],
):
"""groupby=[] is falsy — popped but columns should default to []."""
raw_query_object = raw_query_context["queries"][0]
raw_query_object.pop("columns", None)
raw_query_object["groupby"] = []
query_object = query_object_factory.create(
raw_query_context["result_type"], **raw_query_object
)
assert query_object.columns == []
assert not hasattr(query_object, "groupby")
def test_deprecated_granularity_sqla_renamed_to_granularity(
self,
query_object_factory: QueryObjectFactory,
raw_query_context: dict[str, Any],
):
"""granularity_sqla in stored query_context should be renamed to granularity."""
raw_query_object = raw_query_context["queries"][0]
raw_query_object.pop("granularity", None)
raw_query_object["granularity_sqla"] = "ds"
query_object = query_object_factory.create(
raw_query_context["result_type"], **raw_query_object
)
assert query_object.granularity == "ds"
def test_deprecated_timeseries_limit_renamed_to_series_limit(
self,
query_object_factory: QueryObjectFactory,
raw_query_context: dict[str, Any],
):
"""timeseries_limit should be renamed to series_limit."""
raw_query_object = raw_query_context["queries"][0]
raw_query_object.pop("series_limit", None)
raw_query_object["timeseries_limit"] = 5
query_object = query_object_factory.create(
raw_query_context["result_type"], **raw_query_object
)
assert query_object.series_limit == 5
def test_deprecated_timeseries_limit_zero_is_not_renamed(
self,
query_object_factory: QueryObjectFactory,
raw_query_context: dict[str, Any],
):
"""timeseries_limit=0 is falsy — popped; series_limit defaults to 0."""
raw_query_object = raw_query_context["queries"][0]
raw_query_object.pop("series_limit", None)
raw_query_object["timeseries_limit"] = 0
query_object = query_object_factory.create(
raw_query_context["result_type"], **raw_query_object
)
# 0 is falsy so it does not propagate — QueryObject default is also 0
assert query_object.series_limit == 0