Compare commits

...

7 Commits

Author SHA1 Message Date
Evan
6814bc8925 test: annotate query_obj with QueryObjectDict instead of casting at call site
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 04:26:05 -07:00
Evan
4e8d0e0abe test: add type hints to new orderby jinja test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:01:01 -07:00
Evan
5da4fe7996 fix(sqla): render Jinja templates in calculated columns used via orderby adhoc metrics
A calculated column's Jinja expression was rendered correctly in SELECT,
WHERE, and GROUP BY, but not when the column was referenced by a SIMPLE
adhoc metric (e.g. MAX(calc_col)) used in ORDER BY, since
`adhoc_metric_to_sqla` wasn't passed the template processor at that call
site in `get_sqla_query`. Add a failing-first integration test that pins
the bug, then fix it by threading `template_processor` through.

Fixes #29378
2026-07-07 17:21:31 -07:00
dependabot[bot]
f1c0a461fd chore(deps): bump react-markdown from 9.0.7 to 10.1.0 in /superset-frontend/packages/superset-ui-core (#41805)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:46:07 -07:00
SBIN2010
ef10501e6b feat(table v2): add tooltip to table header (#39287) 2026-07-07 16:44:50 -07:00
jesperct
17ae25b8d4 fix(explore): resolve adhoc Custom SQL dimension in Bubble chart (#41861) 2026-07-07 16:44:24 -07:00
Đỗ Trọng Hải
2a77c0156a fix(frontend/setup): sanitize returned client error message when shown as HTML content (#41768)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-07 16:44:04 -07:00
14 changed files with 456 additions and 10 deletions

View File

@@ -34018,9 +34018,9 @@
"license": "BSD-3-Clause"
},
"node_modules/react-markdown": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
"integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
@@ -41919,7 +41919,7 @@
"react-draggable": "^4.7.0",
"react-error-boundary": "^6.1.2",
"react-js-cron": "^5.2.0",
"react-markdown": "^9.0.7",
"react-markdown": "^10.1.0",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",

View File

@@ -55,7 +55,7 @@
"react-draggable": "^4.7.0",
"react-error-boundary": "^6.1.2",
"react-js-cron": "^5.2.0",
"react-markdown": "^9.0.7",
"react-markdown": "^10.1.0",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",

View File

@@ -547,6 +547,7 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
paginationPageSizeSelector={PAGE_SIZE_OPTIONS}
suppressDragLeaveHidesColumns
pinnedBottomRowData={showTotals ? [cleanedTotals] : undefined}
tooltipShowDelay={500}
localeText={{
// Pagination controls
next: t('Next'),

View File

@@ -448,10 +448,18 @@ export default function TableChart<D extends DataRecord = DataRecord>(
/>
);
const columnsForKeys = isUsingTimeComparison
? (filteredColumns as InputColumn[])
: (columns as InputColumn[]);
const descriptionsKey = columnsForKeys
.map(col => `${col.key}:${col.description ?? ''}`)
.join('|');
return (
<StyledChartContainer height={height}>
<AgGridDataTable
gridHeight={gridHeight}
key={descriptionsKey}
data={data || []}
colDefsFromProps={colDefs}
includeSearch={!!includeSearch}

View File

@@ -346,6 +346,7 @@ const processColumns = memoizeOne(function processColumns(
column_config: columnConfig = {},
query_mode: queryMode,
},
rawDatasource,
queriesData,
} = props;
const granularity = extractTimegrain(props.rawFormData);
@@ -385,6 +386,19 @@ const processColumns = memoizeOne(function processColumns(
? config.currencyFormat
: savedCurrency;
// Internal names of metrics expressed as percentages have a "%" prefix,
// however, their storage locations are defined in rawDatasource.metrics using the original names.
const metricLookupKey = key.startsWith('%') ? key.slice(1) : key;
const description =
rawDatasource.columns?.find(
(item: { column_name?: string; description?: string | null }) =>
item.column_name === key,
)?.description ??
rawDatasource.metrics?.find(
(item: { metric_name?: string; description?: string | null }) =>
item.metric_name === metricLookupKey,
)?.description;
let formatter;
if (isTime || config.d3TimeFormat) {
@@ -431,6 +445,7 @@ const processColumns = memoizeOne(function processColumns(
isPercentMetric,
formatter,
config,
description,
};
})
.sort((a, b) => {

View File

@@ -192,6 +192,7 @@ export interface InputColumn {
TimeFormatter | NumberFormatter | CustomFormatter | CurrencyFormatter;
originalLabel?: string;
metricName?: string;
description?: string;
}
export type ValueRange = [number, number] | null;

View File

@@ -19,12 +19,34 @@
import { isEqualArray } from '@superset-ui/core';
import { TableChartProps } from '../types';
const getDescriptions = (props: TableChartProps) => {
const colnames = props.queriesData?.[0]?.colnames || [];
const columns = props.rawDatasource?.columns || [];
const metrics = props.rawDatasource?.metrics || [];
return colnames.map((key: string) => {
// Internal names of metrics expressed as percentages have a "%" prefix,
// however, their storage locations are defined in rawDatasource.metrics using the original names.
const metricLookupKey = key.startsWith('%') ? key.slice(1) : key;
return (
columns.find((item: { column_name: string }) => item.column_name === key)
?.description ??
metrics.find(
(item: { metric_name: string }) => item.metric_name === metricLookupKey,
)?.description
);
});
};
export default function isEqualColumns(
propsA: TableChartProps[],
propsB: TableChartProps[],
) {
const a = propsA[0];
const b = propsB[0];
const descA = getDescriptions(a);
const descB = getDescriptions(b);
return (
a.datasource.columnFormats === b.datasource.columnFormats &&
a.datasource.currencyFormats === b.datasource.currencyFormats &&
@@ -41,6 +63,7 @@ export default function isEqualColumns(
JSON.stringify(a.formData.extraFormData || null) ===
JSON.stringify(b.formData.extraFormData || null) &&
JSON.stringify(a.rawFormData.column_config || null) ===
JSON.stringify(b.rawFormData.column_config || null)
JSON.stringify(b.rawFormData.column_config || null) &&
JSON.stringify(descA) === JSON.stringify(descB)
);
}

View File

@@ -290,6 +290,7 @@ export const useColDefs = ({
return {
field: colId,
headerName: getHeaderLabel(col),
headerTooltip: col.description,
valueFormatter: p => valueFormatter(p, col),
valueGetter: p => valueGetter(p, col),
cellStyle: p => {

View File

@@ -0,0 +1,266 @@
/**
* 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 transformProps from '../src/transformProps';
import { TableChartProps } from '../src/types';
import { GenericDataType } from '@apache-superset/core/common';
import { QueryMode } from '@superset-ui/core';
function createMockChartProps(
overrides: Partial<TableChartProps> = {},
): TableChartProps {
const defaultProps = {
height: 400,
width: 800,
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: [],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
},
queriesData: [
{
data: [],
colnames: [],
coltypes: [],
rowcount: 0,
applied_filters: [],
rejected_filters: [],
},
],
datasource: {
columns: [],
metrics: [],
columnFormats: {},
currencyFormats: {},
verboseMap: {},
},
rawDatasource: {
columns: [],
metrics: [],
},
filterState: {},
hooks: { setDataMask: jest.fn(), onChartStateChange: jest.fn() },
ownState: {},
emitCrossFilters: false,
theme: {},
...overrides,
};
return defaultProps as unknown as TableChartProps;
}
test('extracts description from datasource.columns for a regular column', () => {
const props = createMockChartProps({
queriesData: [
{
data: [{ col1: 'value' }],
colnames: ['col1'],
coltypes: [GenericDataType.String],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
} as unknown as TableChartProps['queriesData'][number],
],
rawDatasource: {
columns: [
{ column_name: 'col1', description: 'This is a column description' },
],
metrics: [],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === 'col1');
expect(columnMeta).toBeDefined();
expect(columnMeta!.description).toBe('This is a column description');
});
test('extracts description from datasource.metrics for a metric column', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: ['sum_sales'],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
},
queriesData: [
{
data: [{ sum_sales: 100 }],
colnames: ['sum_sales'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
rawDatasource: {
columns: [],
metrics: [
{ metric_name: 'sum_sales', description: 'Total sales amount' },
],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === 'sum_sales');
expect(columnMeta).toBeDefined();
expect(columnMeta!.description).toBe('Total sales amount');
});
test('prefers column description over metric description when both exist with same key', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: ['revenue'],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
},
queriesData: [
{
data: [{ revenue: 500 }],
colnames: ['revenue'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
rawDatasource: {
columns: [{ column_name: 'revenue', description: 'Column desc' }],
metrics: [{ metric_name: 'revenue', description: 'Metric desc' }],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === 'revenue');
expect(columnMeta!.description).toBe('Column desc');
});
test('handles percent metrics correctly uses base metric name for lookup', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: ['profit'],
percent_metrics: ['profit'],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
},
queriesData: [
{
data: [{ '%profit': 0.15 }],
colnames: ['%profit'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
rawDatasource: {
columns: [],
metrics: [
{ metric_name: 'profit', description: 'Profit margin percent' },
],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === '%profit');
expect(columnMeta).toBeDefined();
expect(columnMeta!.description).toBe('Profit margin percent');
});
test('sets description to undefined when no matching column or metric is found', () => {
const props = createMockChartProps({
queriesData: [
{
data: [{ unknown_col: 'x' }],
colnames: ['unknown_col'],
coltypes: [GenericDataType.String],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
rawDatasource: {
columns: [],
metrics: [],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === 'unknown_col');
expect(columnMeta!.description).toBeUndefined();
});
test('uses description from column even when verboseMap renames the column', () => {
const props = createMockChartProps({
queriesData: [
{
data: [{ col_x: 10 }],
colnames: ['col_x'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
datasource: {
columns: [],
metrics: [],
columnFormats: {},
currencyFormats: {},
verboseMap: { col_x: 'Custom Label' },
} as unknown as TableChartProps['datasource'],
rawDatasource: {
columns: [
{ column_name: 'col_x', description: 'Original column description' },
],
metrics: [],
},
});
const result = transformProps(props);
const { columns } = result;
const columnMeta = columns.find(c => c.key === 'col_x');
expect(columnMeta!.label).toBe('Custom Label');
expect(columnMeta!.description).toBe('Original column description');
});

View File

@@ -23,6 +23,7 @@ import {
CategoricalColorNamespace,
getNumberFormatter,
AxisType,
getColumnLabel,
getMetricLabel,
NumberFormatter,
tooltipHtml,
@@ -140,13 +141,17 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
const xAxisLabel: string = getMetricLabel(x);
const yAxisLabel: string = getMetricLabel(y);
const sizeLabel: string = getMetricLabel(size);
const entityLabel: string = getColumnLabel(entity);
const seriesLabel: string | undefined = bubbleSeries
? getColumnLabel(bubbleSeries)
: undefined;
const refs: Refs = {};
data.forEach(datum => {
const dataName = bubbleSeries ? datum[bubbleSeries] : datum[entity];
const dataName = seriesLabel ? datum[seriesLabel] : datum[entityLabel];
const name = dataName ? String(dataName) : NULL_STRING;
const bubbleSeriesValue = bubbleSeries ? datum[bubbleSeries] : null;
const bubbleSeriesValue = seriesLabel ? datum[seriesLabel] : null;
series.push({
name,
@@ -155,7 +160,7 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
datum[xAxisLabel],
datum[yAxisLabel],
datum[sizeLabel],
datum[entity],
datum[entityLabel],
bubbleSeriesValue as any,
],
],

View File

@@ -153,6 +153,78 @@ describe('Bubble transformProps', () => {
});
});
describe('adhoc Custom SQL dimension', () => {
const adhocColumn = {
expressionType: 'SQL',
sqlExpression: 'EXTRACT(YEAR FROM ds)',
label: 'year',
};
const adhocQueriesData = [
{
data: [
{
year: 1992,
customer_name: 'AV Stores, Co.',
count: 10,
'SUM(price_each)': 20,
'SUM(sales)': 30,
},
{
year: 1993,
customer_name: 'Alpha Cognac',
count: 40,
'SUM(price_each)': 50,
'SUM(sales)': 60,
},
],
},
];
test('resolves the entity label so an adhoc dimension is not rendered as NULL', () => {
const formData: SqlaFormData = { ...defaultFormData, entity: adhocColumn };
const chartProps = new ChartProps({
...chartConfig,
queriesData: adhocQueriesData,
formData,
});
const result = transformProps(chartProps as EchartsBubbleChartProps);
expect((result.echartOptions.legend as any).data).toEqual(['1992', '1993']);
expect(result.echartOptions.series).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: '1992',
data: [[10, 20, 30, 1992, null]],
}),
]),
);
});
test('resolves the series label so an adhoc bubble series is not rendered as NULL', () => {
const formData: SqlaFormData = {
...defaultFormData,
entity: 'customer_name',
series: adhocColumn,
};
const chartProps = new ChartProps({
...chartConfig,
queriesData: adhocQueriesData,
formData,
});
const result = transformProps(chartProps as EchartsBubbleChartProps);
expect((result.echartOptions.legend as any).data).toEqual(['1992', '1993']);
expect(result.echartOptions.series).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: '1992',
data: [[10, 20, 30, 'AV Stores, Co.', 1992]],
}),
]),
);
});
});
describe('Bubble formatTooltip', () => {
const dollerFormatter = getNumberFormatter('$,.2f');
const percentFormatter = getNumberFormatter(',.1%');

View File

@@ -22,6 +22,7 @@ import {
SupersetClient,
getClientErrorObject,
ClientErrorObject,
sanitizeHtml,
} from '@superset-ui/core';
import setupErrorMessages from 'src/setup/setupErrorMessages';
@@ -41,7 +42,7 @@ function showApiMessage(resp: ClientErrorObject) {
const severity = resp.severity || 'info';
$(template)
.addClass(`alert-${severity}`)
.append(resp.message || '')
.append(sanitizeHtml(resp.message || ''))
.appendTo($('#alert-container'));
}

View File

@@ -3331,9 +3331,16 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
)
if utils.is_adhoc_metric(col):
# add adhoc sort by column to columns_by_name if not exists
# Pass the template processor so a SIMPLE adhoc metric that
# references a calculated column has that column's Jinja
# expression rendered, matching SELECT/WHERE/GROUP BY. This
# is a no-op for the SQL expression type, whose
# `sqlExpression` was already rendered above; `processed`
# only guards that branch.
col = self.adhoc_metric_to_sqla(
col,
columns_by_name,
template_processor=template_processor,
processed=True,
)
# use the existing instance, if possible

View File

@@ -31,6 +31,7 @@ from superset.db_engine_specs.odps import OdpsBaseEngineSpec, OdpsEngineSpec
from superset.db_engine_specs.sqlite import SqliteEngineSpec
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.sql.parse import Table
from superset.superset_typing import QueryObjectDict
from superset.utils.database import get_example_database
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.test_app import app
@@ -202,6 +203,51 @@ class SupersetTestCases(SupersetTestCase):
in sql
)
@mock.patch("superset.models.core.Database.db_engine_spec", BaseEngineSpec)
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
def test_jinja_calculated_column_in_order_by(self) -> None:
"""
A calculated column referenced by a SIMPLE adhoc metric in `orderby`
should have its Jinja template rendered, the same way it is for
SELECT/WHERE/GROUP BY.
"""
table = self.get_table(name="birth_names")
TableColumn(
column_name="gender_cc_jinja",
type="VARCHAR(255)",
table=table,
expression="""
case
when gender='boy' then {{ "'male'" }}
else {{ "'female'" }}
end
""",
)
table.database.sqlalchemy_uri = "sqlite://"
query_obj: QueryObjectDict = {
"groupby": ["gender_cc_jinja"],
"is_timeseries": False,
"filter": [],
"orderby": [
(
{
"expressionType": "SIMPLE",
"column": {"column_name": "gender_cc_jinja"},
"aggregate": "MAX",
"label": "max_gender_cc_jinja",
},
True,
)
],
}
sql = table.get_query_str(query_obj)
orderby_clause = sql.split("ORDER BY")[1]
assert "{%" not in orderby_clause
assert "{{" not in orderby_clause
assert "'male'" in orderby_clause
assert "'female'" in orderby_clause
def test_time_grain_denylist():
config = app.config.copy()