Compare commits

...

15 Commits

Author SHA1 Message Date
amaannawab923
70843d668a fix(plugin-chart-ag-grid-table): persist Value Aggregation "None" selection (#107166)
The grid-state change detector hashed only column order, sorts and filters,
so changing a column's Value Aggregation (aggFunc) never triggered
onColumnStateChange. On reload the column fell back to its default
initialAggFunc (sum-like), which is why selecting "None" reverted to Sum
while other values appeared to stick.

Extract the signature into getColumnStateSignature() and include per-column
aggFunc, normalizing the "None" case (null/undefined) to a stable sentinel so
the change is captured and persisted. Add unit tests.
2026-06-24 17:10:13 +05:30
Shlummie
a57b5f6078 fix(deckgl): show dashboard filter badges for multi-layer charts (#40003)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:14:25 -07:00
MelikHajlawi
d1b523b97f docs: fix placeholder text in @superset-ui/core README (#40002)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:07:24 -07:00
Shashwati Bhattacharyaa
91188a0302 fix(config): Wire LOGO_TARGET_PATH and document custom spinner usage (#36951)
Co-authored-by: Shashwati <shashwatibhattacaharya21.2@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-06-24 01:56:15 -07:00
MUHAMMED SINAN D
ac234d0fb2 fix(dashboard): prevent x-axis clipping when toggling chart description (#38307) 2026-06-24 01:54:43 -07:00
felipegr0ssi
8eb753eab2 fix(dashboard): keep native filter dropdown from covering input (#40032)
Co-authored-by: feehgrossi <felipe.leite@sptech.school>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:53:44 -07:00
abhyudaytomar
779fa13679 fix(security): prevent duplicate items in permissions dropdown on scroll (#39292)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:53:27 -07:00
Greg Neighbors
caf81e71d2 feat(mcp): add typed Pydantic response schemas to generate_explore_link tool (#39900)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:53:08 -07:00
Eddy
1b8c6d109d feat: added deterministic field generation to dashboard export (#36339)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 01:41:44 -07:00
Viktor Högberg
eb60e5477b fix(radar): correct legend margin control in the radar chart (#39414) 2026-06-24 01:41:24 -07:00
Puneet Dixit
7b9bcdd951 fix(bigquery): preserve catalog in partition metadata lookup (#40200)
Co-authored-by: Puneet Dixit <rvit23bcs086.rvitm@rvei.edu.in>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 01:41:06 -07:00
ruhz3
d9d395bde1 fix(helm): remove unused SQLALCHEMY_TRACK_MODIFICATIONS setting (#37259) 2026-06-24 01:28:30 -07:00
Jay Masiwal
584d41759b refactor: migrate test files from nested describe blocks and remove stale lint ignores (#39202)
Co-authored-by: Joe Li <joe@preset.io>
2026-06-24 01:19:15 -07:00
abdullah reveha
8f22b71898 feat(chart): enable cross-filter on x-axis labels for bar, line, area and scatter charts (#41111)
Co-authored-by: Abdullah Sahin <you@example.comclear>
2026-06-24 01:17:29 -07:00
omkarhall
1ea3584dcb fix(chart): added Big Number chart support for MAX metric with VARCHAR column (#41182) 2026-06-24 01:11:13 -07:00
44 changed files with 2081 additions and 530 deletions

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.17.2 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.17.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.17.2](https://img.shields.io/badge/Version-0.17.2-informational?style=flat-square)
![Version: 0.17.3](https://img.shields.io/badge/Version-0.17.3-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application

View File

@@ -108,8 +108,6 @@ else:
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
{{- end }}
SQLALCHEMY_TRACK_MODIFICATIONS = True
class CeleryConfig:
imports = ("superset.sql_lab", )
broker_url = CELERY_REDIS_URL

View File

@@ -22,21 +22,50 @@ under the License.
[![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/core)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Fcore?style=flat)](https://libraries.io/npm/@superset-ui%2Fcore)
Description
The core package for Apache Superset's frontend. It provides shared utilities,
types, and abstractions used across all Superset chart plugins and UI components.
Key modules include:
- **query** — Utilities for building queries and calling the Superset API
(including `makeApi`)
- **number-format** — Number formatting helpers powered by d3-format
- **time-format** — Time/date formatting helpers powered by d3-time-format
- **connection** — `SupersetClient`, the HTTP client for the Superset REST API
- **chart** — Base classes and types for building chart plugins
> **Note:** i18n utilities (`t`, `tn`, etc.) are no longer part of this package.
> They now live in `@apache-superset/core`, imported from
> `@apache-superset/core/translation`.
#### Example usage
```js
import { xxx } from '@superset-ui/core';
import { getNumberFormatter, makeApi } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
// Format a number
const formatter = getNumberFormatter('.2f');
console.log(formatter(1234.5)); // "1234.50"
// Translate a string
console.log(t('Hello %s', 'world'));
// Call a Superset API endpoint
const fetchDashboards = makeApi({
method: 'GET',
endpoint: '/api/v1/dashboard',
});
```
#### API
`fn(args)`
- TBD
### Development
`@data-ui/build-config` is used to manage the build configuration for this package including babel
builds, jest testing, eslint, and prettier.
`@data-ui/build-config` is used to manage the build configuration for this package
including babel builds, jest testing, eslint, and prettier.
Run tests:
```bash
cd superset-frontend
npx jest packages/superset-ui-core
```

View File

@@ -57,6 +57,7 @@ import { SearchOption, SortByItem } from '../types';
import getInitialSortState, { shouldSort } from '../utils/getInitialSortState';
import getInitialFilterModel from '../utils/getInitialFilterModel';
import reconcileColumnState from '../utils/reconcileColumnState';
import getColumnStateSignature from '../utils/getColumnStateSignature';
import { PAGE_SIZE_OPTIONS } from '../consts';
import { getCompleteFilterState } from '../utils/filterStateManager';
@@ -337,11 +338,11 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
timestamp: Date.now(),
};
const stateHash = JSON.stringify({
columnOrder: columnState.map(c => c.colId),
sorts: sortModel,
filters: filterModel,
});
const stateHash = getColumnStateSignature(
columnState,
sortModel,
filterModel,
);
if (stateHash !== lastCapturedStateRef.current) {
lastCapturedStateRef.current = stateHash;

View File

@@ -0,0 +1,54 @@
/**
* 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 type { ColumnState } from '@superset-ui/core/components/ThemedAgGridReact';
type SortModelEntry = {
colId: string;
sort: 'asc' | 'desc';
sortIndex: number;
};
/**
* Builds a stable signature of the parts of AG Grid column state that must be
* persisted (and therefore must trigger an `onColumnStateChange` when they
* change): column order, per-column value aggregation, sorts and filters.
*
* The value aggregation (`aggFunc`) is normalized so that "no aggregation"
* (the AG Grid "None" option, represented as `null`/`undefined`) produces a
* distinct, stable signature. Without this, switching a column's Value
* Aggregation to "None" did not change the signature, so the change was never
* captured and the column reverted to its default aggregation on reload.
*/
export default function getColumnStateSignature(
columnState: ColumnState[],
sortModel: SortModelEntry[],
filterModel: Record<string, unknown>,
): string {
return JSON.stringify({
columnOrder: columnState.map(col => col.colId),
aggregations: columnState.map(col => ({
colId: col.colId,
// Normalize falsy "None" (null/undefined) to an explicit sentinel so it
// is preserved and distinguishable from a real aggregation function.
aggFunc: col.aggFunc ?? null,
})),
sorts: sortModel,
filters: filterModel,
});
}

View File

@@ -0,0 +1,75 @@
/**
* 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 getColumnStateSignature from '../../src/utils/getColumnStateSignature';
const sortModel: any[] = [];
const filterModel = {};
test('signature changes when a column value aggregation changes', () => {
const before = getColumnStateSignature(
[{ colId: 'sales', aggFunc: 'sum' }] as any,
sortModel,
filterModel,
);
const after = getColumnStateSignature(
[{ colId: 'sales', aggFunc: 'avg' }] as any,
sortModel,
filterModel,
);
expect(after).not.toEqual(before);
});
test('switching value aggregation to "None" (null/undefined) changes the signature', () => {
// Regression test for #107166: selecting "None" must be captured so it
// persists instead of reverting to the default Sum aggregation on reload.
const sumState = getColumnStateSignature(
[{ colId: 'sales', aggFunc: 'sum' }] as any,
sortModel,
filterModel,
);
const noneNull = getColumnStateSignature(
[{ colId: 'sales', aggFunc: null }] as any,
sortModel,
filterModel,
);
const noneUndefined = getColumnStateSignature(
[{ colId: 'sales' }] as any,
sortModel,
filterModel,
);
expect(noneNull).not.toEqual(sumState);
expect(noneUndefined).not.toEqual(sumState);
// null and undefined both represent "None" and must be treated identically.
expect(noneNull).toEqual(noneUndefined);
});
test('signature is stable when nothing changes', () => {
const a = getColumnStateSignature(
[{ colId: 'sales', aggFunc: 'sum' }] as any,
sortModel,
filterModel,
);
const b = getColumnStateSignature(
[{ colId: 'sales', aggFunc: 'sum' }] as any,
sortModel,
filterModel,
);
expect(a).toEqual(b);
});

View File

@@ -231,6 +231,56 @@ describe('BigNumberTotal transformProps', () => {
expect(result.headerFormatter(500)).toBe('$500');
});
test('should pass through non-numeric raw string when parseMetricValue returns null (e.g. VARCHAR MAX)', () => {
const { parseMetricValue } = jest.requireMock('../utils');
parseMetricValue.mockReturnValueOnce(null);
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [{ value: 'some-varchar-result' }],
coltypes: [GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBe('some-varchar-result');
});
test('should pass through numeric-looking VARCHAR string literally (e.g. "123")', () => {
const { parseMetricValue } = jest.requireMock('../utils');
parseMetricValue.mockReturnValueOnce(null);
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [{ value: '123' }],
coltypes: [GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBe('123');
});
test('should propagate colorThresholdFormatters from getColorFormatters', () => {
// Override the getColorFormatters mock to return specific value
const mockFormatters = [{ formatter: 'red' }];

View File

@@ -79,8 +79,15 @@ export default function transformProps(
const formattedSubtitleFontSize = subtitle?.trim()
? (subtitleFontSize ?? PROPORTION.SUBHEADER)
: (subheaderFontSize ?? subtitleFontSize ?? PROPORTION.SUBHEADER);
const rawValue = data.length === 0 ? null : data[0][metricName];
const parsedValue = rawValue == null ? null : parseMetricValue(rawValue);
const bigNumber =
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
parsedValue === null &&
typeof rawValue === 'string' &&
rawValue.trim() !== ''
? rawValue
: parsedValue;
let metricEntry: Metric | undefined;
if (chartProps.datasource?.metrics) {

View File

@@ -189,8 +189,10 @@ function BigNumberVis({
text = t('No data');
} else if (typeof bigNumber === 'number') {
text = headerFormatter(bigNumber);
} else if (typeof bigNumber === 'string') {
text = bigNumber;
} else {
// For string/boolean/Date values, convert to number if possible, else show as string
// For boolean/Date values, convert to number if possible, else show as string
const numValue = Number(bigNumber);
text = Number.isNaN(numValue)
? String(bigNumber)

View File

@@ -331,10 +331,16 @@ export default function transformProps(
type: legendType,
});
const chartPadding = getChartPadding(
showLegend,
legendOrientation,
effectiveLegendMargin,
);
const series: RadarSeriesOption[] = [
{
type: 'radar',
...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin),
...chartPadding,
animation: false,
emphasis: {
label: {
@@ -361,6 +367,15 @@ export default function transformProps(
numberFormatter,
);
const centerX = width
? ((width + chartPadding.left - chartPadding.right) / 2 / width) * 100
: 50;
const centerY = height
? ((height + chartPadding.top - chartPadding.bottom) / 2 / height) * 100
: 50;
const radarCenter: [string, string] = [`${centerX}%`, `${centerY}%`];
const echartOptions: EChartsCoreOption = {
grid: {
...defaultGrid,
@@ -390,6 +405,7 @@ export default function transformProps(
color: theme.colorSplit,
},
},
center: radarCenter,
splitArea: {
show: true,
areaStyle: {

View File

@@ -23,6 +23,7 @@ import {
} from '../../../../spec/helpers/testing-library';
import { AxisType } from '@superset-ui/core';
import type { EChartsCoreOption } from 'echarts/core';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import type { ReactNode } from 'react';
import {
LegendOrientation,
@@ -202,11 +203,15 @@ const defaultProps: TimeseriesChartTransformedProps = {
onFocusedSeries: jest.fn(),
};
function getLatestHeight() {
function getLatestEchartProps() {
const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined();
const [props] = lastCall as [EchartsProps];
return props.height;
return props;
}
function getLatestHeight() {
return getLatestEchartProps().height;
}
test('observes extra control height changes when ResizeObserver is available', async () => {
@@ -335,6 +340,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
const clickHandler = props.eventHandlers?.click;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name
data: ['Product A', 100], // X-axis value is 'Product A'
name: 'Product A',
@@ -361,6 +367,149 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
}
});
test('emits cross-filter on category value for horizontal bar clicks', async () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
}}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [100, 'Product A'],
name: 'Product A',
dataIndex: 0,
});
await waitFor(
() => {
expect(setDataMaskMock).toHaveBeenCalled();
},
{ timeout: 500 },
);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'category_column',
op: 'IN',
val: ['Product A'],
},
]);
});
test('uses rendered categorical axis for query event handlers', () => {
render(
<EchartsTimeseries
{...defaultProps}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
'xAxis.category',
);
cleanup();
mockEchart.mockReset();
render(
<EchartsTimeseries
{...defaultProps}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
}}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
'yAxis.category',
);
});
test('emits cross-filter from horizontal categorical axis label clicks', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
}}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
const labelClickHandler =
getLatestEchartProps().queryEventHandlers?.[0].handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
value: 'Product A',
} as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'category_column',
op: 'IN',
val: ['Product A'],
},
]);
});
test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'xAxis',
name: 'Product A',
});
await new Promise(resolve => setTimeout(resolve, 400));
expect(setDataMaskMock).not.toHaveBeenCalled();
});
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
const setDataMaskMock = jest.fn();
@@ -385,6 +534,7 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
const clickHandler = props.eventHandlers?.click;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales',
data: [1609459200000, 100], // Timestamp
name: '2021-01-01',
@@ -407,6 +557,10 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
...defaultProps,
emitCrossFilters: true,
setDataMask: setDataMaskMock,
formData: {
...defaultFormData,
orientation: OrientationType.Horizontal,
},
groupby: [], // No dimensions
xAxis: {
label: 'category_column',
@@ -423,6 +577,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
const clickHandler = props.eventHandlers?.click;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name
data: [100, 'Product A'], // Horizontal: value first, category second
name: 'Product A',
@@ -457,6 +612,10 @@ test('context menu cross-filter uses the category value for a horizontal categor
...defaultProps,
emitCrossFilters: true,
onContextMenu: onContextMenuMock,
formData: {
...defaultFormData,
orientation: OrientationType.Horizontal,
},
groupby: [], // No dimensions
xAxis: {
label: 'category_column',
@@ -474,6 +633,7 @@ test('context menu cross-filter uses the category value for a horizontal categor
expect(contextMenuHandler).toBeDefined();
if (contextMenuHandler) {
await contextMenuHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name
data: [100, 'Product A'], // Horizontal: value first, category second
name: 'Product A',

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DTTM_ALIAS,
BinaryQueryObjectFilterClause,
@@ -27,12 +27,15 @@ import {
LegendState,
ensureIsArray,
} from '@superset-ui/core';
import type { ViewRootGroup } from 'echarts/types/src/util/types';
import type {
ECElementEvent,
ViewRootGroup,
} from 'echarts/types/src/util/types';
import type GlobalModel from 'echarts/types/src/model/Global';
import type ComponentModel from 'echarts/types/src/model/Component';
import { EchartsHandler, EventHandlers } from '../types';
import Echart from '../components/Echart';
import { TimeseriesChartTransformedProps } from './types';
import { OrientationType, TimeseriesChartTransformedProps } from './types';
import { formatSeriesName } from '../utils/series';
import { ExtraControls } from '../components/ExtraControls';
@@ -218,6 +221,26 @@ export default function EchartsTimeseries({
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
const canCrossFilterByXAxis =
!hasDimensions && xAxis.type === AxisType.Category;
const categoryAxisValueIndex =
formData.orientation === OrientationType.Horizontal ? 1 : 0;
const getCategoryAxisValue = useCallback(
(data: unknown, name: unknown) => {
if (Array.isArray(data)) {
const categoryAxisValue = data[categoryAxisValueIndex];
if (
typeof categoryAxisValue === 'string' ||
typeof categoryAxisValue === 'number'
) {
return categoryAxisValue;
}
}
if (typeof name === 'string' || typeof name === 'number') {
return name;
}
return undefined;
},
[categoryAxisValueIndex],
);
const eventHandlers: EventHandlers = {
click: props => {
@@ -234,12 +257,15 @@ export default function EchartsTimeseries({
// Cross-filter by dimension (original behavior)
const { seriesName: name } = props;
handleChange(name);
} else if (canCrossFilterByXAxis && props.name != null) {
// Cross-filter by X-axis value when no dimensions (issue #25334).
// Use `name` (the category-axis value) instead of `data[0]`: for
// horizontal bars the data tuple is value-first, so `data[0]` would
// be the metric value rather than the category (issue #41102).
handleXAxisChange(props.name);
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
// Cross-filter by X-axis value when no dimensions (issue #25334)
const categoryAxisValue = getCategoryAxisValue(
props.data,
props.name,
);
if (categoryAxisValue !== undefined) {
handleXAxisChange(categoryAxisValue);
}
}
}, TIMER_DURATION);
},
@@ -321,10 +347,17 @@ export default function EchartsTimeseries({
let crossFilter;
if (hasDimensions) {
crossFilter = getCrossFilterDataMask(seriesName);
} else if (canCrossFilterByXAxis && eventParams.name != null) {
// Use `name` (the category-axis value), not `data[0]`, so horizontal
// bars cross-filter on the category and not the metric (issue #41102).
crossFilter = getXAxisCrossFilterDataMask(eventParams.name);
} else if (
canCrossFilterByXAxis &&
eventParams.componentType === 'series'
) {
const categoryAxisValue = getCategoryAxisValue(
data,
eventParams.name,
);
if (categoryAxisValue !== undefined) {
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
}
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
@@ -336,6 +369,33 @@ export default function EchartsTimeseries({
},
};
const handleXAxisLabelClick = useCallback(
(event: ECElementEvent) => {
const { value } = event;
if (
canCrossFilterByXAxis &&
(typeof value === 'string' || typeof value === 'number')
) {
handleXAxisChange(value);
}
},
[canCrossFilterByXAxis, handleXAxisChange],
);
const categoryAxis =
formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
const queryEventHandlers = useMemo(
() => [
{
name: 'click',
query: `${categoryAxis}.category`,
handler: handleXAxisLabelClick,
},
],
[categoryAxis, handleXAxisLabelClick],
);
const zrEventHandlers: EventHandlers = {
dblclick: params => {
// clear single click timer
@@ -377,6 +437,7 @@ export default function EchartsTimeseries({
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
queryEventHandlers={queryEventHandlers}
zrEventHandlers={zrEventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}

View File

@@ -889,6 +889,10 @@ export default function transformProps(
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle',
...(xAxisType === AxisType.Category &&
groupBy.length === 0 && {
triggerEvent: true,
}),
axisLabel: {
// When rotation is applied on time axes, hideOverlap can
// aggressively hide the last label. Rotated labels already

View File

@@ -0,0 +1,223 @@
/**
* 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 { render, waitFor } from '../../../../spec/helpers/testing-library';
import type { EChartsCoreOption } from 'echarts/core';
import Echart from './Echart';
import type { EchartsProps } from '../types';
type Handler = (params: unknown) => void;
type Listener = {
query?: string;
handler: Handler;
};
const listeners: Record<string, Listener[]> = {};
const mockChart = {
dispatchAction: jest.fn(),
dispose: jest.fn(),
getOption: jest.fn(() => ({})),
getZr: jest.fn(() => ({
off: jest.fn(),
on: jest.fn(),
})),
off: jest.fn((name: string, handler?: Handler) => {
if (!handler) {
delete listeners[name];
return;
}
listeners[name] = (listeners[name] || []).filter(
listener => listener.handler !== handler,
);
}),
on: jest.fn(
(name: string, queryOrHandler: string | Handler, handler?: Handler) => {
listeners[name] = listeners[name] || [];
listeners[name].push(
handler
? { query: queryOrHandler as string, handler }
: { handler: queryOrHandler as Handler },
);
},
),
resize: jest.fn(),
setOption: jest.fn(),
};
jest.mock('echarts/core', () => ({
init: jest.fn(() => mockChart),
registerLocale: jest.fn(),
use: jest.fn(),
}));
jest.mock('echarts/charts', () => ({
BarChart: 'BarChart',
BoxplotChart: 'BoxplotChart',
CustomChart: 'CustomChart',
FunnelChart: 'FunnelChart',
GaugeChart: 'GaugeChart',
GraphChart: 'GraphChart',
HeatmapChart: 'HeatmapChart',
LineChart: 'LineChart',
PieChart: 'PieChart',
RadarChart: 'RadarChart',
SankeyChart: 'SankeyChart',
ScatterChart: 'ScatterChart',
SunburstChart: 'SunburstChart',
TreeChart: 'TreeChart',
TreemapChart: 'TreemapChart',
}));
jest.mock('echarts/components', () => ({
AriaComponent: 'AriaComponent',
DataZoomComponent: 'DataZoomComponent',
GraphicComponent: 'GraphicComponent',
GridComponent: 'GridComponent',
LegendComponent: 'LegendComponent',
MarkAreaComponent: 'MarkAreaComponent',
MarkLineComponent: 'MarkLineComponent',
TitleComponent: 'TitleComponent',
ToolboxComponent: 'ToolboxComponent',
TooltipComponent: 'TooltipComponent',
VisualMapComponent: 'VisualMapComponent',
}));
jest.mock('echarts/features', () => ({
LabelLayout: 'LabelLayout',
}));
jest.mock('echarts/renderers', () => ({
CanvasRenderer: 'CanvasRenderer',
}));
const initialState = {
common: {
locale: 'en',
},
dashboardState: {
isRefreshing: false,
},
};
const defaultProps: EchartsProps = {
echartOptions: { series: [] } as EChartsCoreOption,
height: 100,
refs: {},
width: 100,
};
const renderEchart = (props: Partial<EchartsProps> = {}) => (
<Echart {...defaultProps} {...props} />
);
const trigger = (name: string) => {
(listeners[name] || []).forEach(listener => listener.handler({}));
};
beforeEach(() => {
Object.keys(listeners).forEach(name => {
delete listeners[name];
});
Object.values(mockChart).forEach(value => {
if (jest.isMockFunction(value)) {
value.mockClear();
}
});
});
test('replaces stale query event handlers without clearing regular event handlers', async () => {
const regularClickHandler = jest.fn();
const firstQueryHandler = jest.fn();
const secondQueryHandler = jest.fn();
const { rerender } = render(
renderEchart({
eventHandlers: {
click: regularClickHandler,
},
queryEventHandlers: [
{
handler: firstQueryHandler,
name: 'click',
query: 'xAxis.category',
},
],
}),
{ initialState, useRedux: true },
);
await waitFor(() =>
expect(mockChart.on).toHaveBeenCalledWith(
'click',
'xAxis.category',
firstQueryHandler,
),
);
rerender(
renderEchart({
eventHandlers: {
click: regularClickHandler,
},
queryEventHandlers: [
{
handler: secondQueryHandler,
name: 'click',
query: 'xAxis.category',
},
],
}),
);
await waitFor(() =>
expect(mockChart.on).toHaveBeenCalledWith(
'click',
'xAxis.category',
secondQueryHandler,
),
);
trigger('click');
expect(regularClickHandler).toHaveBeenCalledTimes(1);
expect(firstQueryHandler).not.toHaveBeenCalled();
expect(secondQueryHandler).toHaveBeenCalledTimes(1);
regularClickHandler.mockClear();
secondQueryHandler.mockClear();
rerender(
renderEchart({
eventHandlers: {
click: regularClickHandler,
},
queryEventHandlers: [],
}),
);
await waitFor(() =>
expect(mockChart.off).toHaveBeenCalledWith('click', secondQueryHandler),
);
trigger('click');
expect(regularClickHandler).toHaveBeenCalledTimes(1);
expect(firstQueryHandler).not.toHaveBeenCalled();
expect(secondQueryHandler).not.toHaveBeenCalled();
});

View File

@@ -64,7 +64,12 @@ import {
MarkLineComponent,
} from 'echarts/components';
import { LabelLayout } from 'echarts/features';
import { EchartsHandler, EchartsProps, EchartsStylesProps } from '../types';
import {
EchartsHandler,
EchartsProps,
EchartsStylesProps,
QueryEventHandlers,
} from '../types';
import { DEFAULT_LOCALE } from '../constants';
import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
@@ -132,6 +137,7 @@ function Echart(
height,
echartOptions,
eventHandlers,
queryEventHandlers,
zrEventHandlers,
selectedValues = {},
refs,
@@ -147,6 +153,7 @@ function Echart(
}
const [didMount, setDidMount] = useState(false);
const chartRef = useRef<EChartsType>();
const previousQueryEventHandlers = useRef<QueryEventHandlers>([]);
const currentSelection = useMemo(
() => Object.keys(selectedValues) || [],
[selectedValues],
@@ -196,11 +203,19 @@ function Echart(
useEffect(() => {
if (didMount) {
previousQueryEventHandlers.current.forEach(({ name, handler }) => {
chartRef.current?.off(name, handler);
});
Object.entries(eventHandlers || {}).forEach(([name, handler]) => {
chartRef.current?.off(name);
chartRef.current?.on(name, handler);
});
(queryEventHandlers || []).forEach(({ name, query, handler }) => {
chartRef.current?.on(name, query, handler);
});
previousQueryEventHandlers.current = queryEventHandlers || [];
Object.entries(zrEventHandlers || {}).forEach(([name, handler]) => {
chartRef.current?.getZr().off(name);
chartRef.current?.getZr().on(name, handler);
@@ -336,7 +351,15 @@ function Echart(
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
}, [
didMount,
echartOptions,
eventHandlers,
queryEventHandlers,
zrEventHandlers,
theme,
vizType,
]);
// Clear tooltip on refresh start to avoid stale content (#39247)
useEffect(() => {

View File

@@ -34,6 +34,7 @@ import {
} from '@superset-ui/core';
import type { EChartsCoreOption, EChartsType } from 'echarts/core';
import type { TooltipMarker } from 'echarts/types/src/util/format';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import { StackControlsValue } from './constants';
export type EchartsStylesProps = {
@@ -51,6 +52,7 @@ export interface EchartsProps {
width: number;
echartOptions: EChartsCoreOption;
eventHandlers?: EventHandlers;
queryEventHandlers?: QueryEventHandlers;
zrEventHandlers?: EventHandlers;
selectedValues?: Record<number, string>;
forceClear?: boolean;
@@ -105,6 +107,12 @@ export type LegendFormData = {
export type EventHandlers = Record<string, { (props: any): void }>;
export type QueryEventHandlers = {
name: string;
query: string;
handler: (props: ECElementEvent) => void;
}[];
export enum LabelPositionEnum {
Top = 'top',
Left = 'left',

View File

@@ -24,6 +24,7 @@ import {
EchartsRadarChartProps,
EchartsRadarFormData,
} from '../../src/Radar/types';
import { LegendOrientation } from '../../src/types';
interface RadarIndicator {
name: string;
@@ -202,3 +203,58 @@ describe('legend sorting', () => {
]);
});
});
describe('radar center positioning', () => {
const getCenter = (overrides: Partial<EchartsRadarFormData> = {}) => {
const props = new ChartProps({
formData: {
...formData,
showLegend: true,
legendMargin: 100,
...overrides,
},
width: 800,
height: 600,
queriesData,
theme: supersetTheme,
});
const result = transformProps(props as EchartsRadarChartProps);
const { center } = result.echartOptions.radar as {
center: [string, string];
};
return {
x: parseFloat(center[0]),
y: parseFloat(center[1]),
};
};
test('keeps the center when the legend is hidden', () => {
const { x, y } = getCenter({ showLegend: false });
expect(x).toBe(50);
expect(y).toBe(50);
});
test('shifts the center right (away from the legend) when legend is on the left', () => {
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Left });
expect(x).toBeGreaterThan(50);
expect(y).toBe(50);
});
test('shifts the center left (away from the legend) when legend is on the right', () => {
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Right });
expect(x).toBeLessThan(50);
expect(y).toBe(50);
});
test('shifts the center down (away from the legend) when legend is on the top', () => {
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Top });
expect(x).toBe(50);
expect(y).toBeGreaterThan(50);
});
test('shifts the center up (away from the legend) when legend is on the bottom', () => {
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Bottom });
expect(x).toBe(50);
expect(y).toBeLessThan(50);
});
});

View File

@@ -1564,9 +1564,13 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
});
const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as { type: string };
const xAxis = echartOptions.xAxis as {
triggerEvent?: boolean;
type: string;
};
expect(xAxis.type).toBe(AxisType.Category);
expect(xAxis.triggerEvent).toBe(true);
});
test('temporal x coltype wires the time formatter and Time axis', () => {

View File

@@ -379,6 +379,79 @@ test('should fallback to formData state when runtime state not available', () =>
expect(getByTestId('chart-container')).toBeInTheDocument();
});
test('chart height is reduced on first render in expanded state (guards against useEffect regression)', () => {
const DESCRIPTION_HEIGHT = 60;
const CHART_HEIGHT = 300;
// Matches the DEFAULT_HEADER_HEIGHT constant in Chart.tsx.
const DEFAULT_HEADER_HEIGHT = 22;
// Stabilise getHeaderHeight(): emotion injects margin-bottom CSS during
// React's commit phase, so getComputedStyle returns different values in
// initial renders vs re-renders. Mock it to always return empty so
// getHeaderHeight() consistently falls back to DEFAULT_HEADER_HEIGHT.
const getComputedStyleSpy = jest
.spyOn(window, 'getComputedStyle')
.mockReturnValue({
getPropertyValue: () => '',
} as unknown as CSSStyleDeclaration);
// JSDOM doesn't compute layout, so mock offsetHeight to simulate a real
// description element with height.
const offsetHeightSpy = jest
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.classList.contains('slice_description')
? DESCRIPTION_HEIGHT
: 0;
});
// Suppress all passive effects to simulate the first-paint moment — the
// point at which the original useEffect bug caused clipping. useLayoutEffect
// (the fix) runs synchronously before paint and is intentionally NOT mocked
// here. If the implementation were reverted to useEffect, this spy would
// prevent the height measurement and the assertion below would fail.
const useEffectSpy = jest
.spyOn(global.React, 'useEffect')
.mockImplementation(() => {});
const { container } = setup(
{ height: CHART_HEIGHT },
{
charts: {
...defaultState.charts,
[queryId]: {
...defaultState.charts[queryId],
// ChartOverlay renders with an inline height style when loading —
// this is the observable proxy for getChartHeight() without real layout.
chartStatus: 'loading',
},
},
dashboardState: {
...defaultState.dashboardState,
expandedSlices: { [queryId]: true },
},
},
);
const chartHeight = parseInt(
container.querySelector<HTMLDivElement>('.dashboard-chart > div[style]')!
.style.height,
10,
);
// useLayoutEffect must have measured and applied descriptionHeight
// synchronously. If useEffect were used instead, descriptionHeight would
// still be 0 here (suppressed by useEffectSpy) and chartHeight would equal
// CHART_HEIGHT - DEFAULT_HEADER_HEIGHT rather than the value below.
expect(chartHeight).toBe(
CHART_HEIGHT - DEFAULT_HEADER_HEIGHT - DESCRIPTION_HEIGHT,
);
useEffectSpy.mockRestore();
getComputedStyleSpy.mockRestore();
offsetHeightSpy.mockRestore();
});
test('should not show a close button on chart error banners', () => {
const { queryByRole } = setup(
{},

View File

@@ -20,6 +20,7 @@ import cx from 'classnames';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useMemo,
useState,
@@ -318,13 +319,9 @@ const Chart = (props: ChartProps) => {
[dispatch, props.id, sliceVizType],
);
useEffect(() => {
if (isExpanded) {
const descHeight =
isExpanded && descriptionRef.current
? descriptionRef.current?.offsetHeight
: 0;
setDescriptionHeight(descHeight);
useLayoutEffect(() => {
if (isExpanded && descriptionRef.current) {
setDescriptionHeight(descriptionRef.current.offsetHeight);
} else {
setDescriptionHeight(0);
}

View File

@@ -22,6 +22,8 @@ import {
extractLabel,
getAppliedColumnsWithFallback,
getCrossFilterIndicator,
IndicatorStatus,
selectNativeIndicatorsForChart,
} from './selectors';
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -207,6 +209,21 @@ test('getAppliedColumnsWithFallback returns columns from query response when ava
expect(result).toEqual(new Set(['age', 'name']));
});
test('getAppliedColumnsWithFallback returns columns from all query responses', () => {
const chart = {
queriesResponse: [
{
applied_filters: [],
},
{
applied_filters: [{ column: 'age' }, { column: 'name' }],
},
],
};
const result = getAppliedColumnsWithFallback(chart);
expect(result).toEqual(new Set(['age', 'name']));
});
test('getAppliedColumnsWithFallback returns empty set when query response has no applied_filters and no fallback params', () => {
const chart = {
queriesResponse: [{ applied_filters: [] }],
@@ -565,3 +582,47 @@ test('getAppliedColumnsWithFallback prioritizes query response over fallback', (
);
expect(result).toEqual(new Set(['query_column']));
});
test('selectNativeIndicatorsForChart marks rejected filters from later query responses incompatible', () => {
const chartId = 987;
const nativeFilters = {
filter1: {
id: 'filter1',
name: 'Age',
type: NativeFilterType.NativeFilter,
chartsInScope: [chartId],
targets: [{ column: { name: 'age' } }],
},
} as any;
const dataMask = {
filter1: {
id: 'filter1',
filterState: { value: '25' },
extraFormData: {},
},
} as any;
const chart = {
queriesResponse: [
{ rejected_filters: [] },
{ rejected_filters: [{ column: 'age' }] },
],
};
const result = selectNativeIndicatorsForChart(
nativeFilters,
dataMask,
chartId,
chart,
[],
);
expect(result).toEqual([
{
column: 'age',
name: 'Age',
path: ['filter1'],
status: IndicatorStatus.Incompatible,
value: '25',
},
]);
});

View File

@@ -141,9 +141,20 @@ const selectIndicatorsForChartFromFilter = (
}));
};
const getQueryFilterMetadata = (
chart: any,
metadataKey: 'applied_filters' | 'rejected_filters',
) =>
ensureIsArray(chart?.queriesResponse).flatMap(
queryResponse =>
(metadataKey === 'applied_filters'
? queryResponse?.applied_filters
: queryResponse?.rejected_filters) || [],
);
const getAppliedColumns = (chart: any): Set<string> =>
new Set(
(chart?.queriesResponse?.[0]?.applied_filters || []).map(
getQueryFilterMetadata(chart, 'applied_filters').map(
(filter: any) => filter.column,
),
);
@@ -161,8 +172,7 @@ export const getAppliedColumnsWithFallback = (
chartId?: number,
): Set<string> => {
// First try to get from query response (preferred source of truth)
const queryAppliedFilters =
chart?.queriesResponse?.[0]?.applied_filters || [];
const queryAppliedFilters = getQueryFilterMetadata(chart, 'applied_filters');
if (queryAppliedFilters.length > 0) {
return new Set(queryAppliedFilters.map((filter: any) => filter.column));
}
@@ -191,7 +201,7 @@ export const getAppliedColumnsWithFallback = (
const getRejectedColumns = (chart: any): Set<string> =>
new Set(
(chart?.queriesResponse?.[0]?.rejected_filters || []).map((filter: any) =>
getQueryFilterMetadata(chart, 'rejected_filters').map((filter: any) =>
getColumnLabel(filter.column),
),
);

View File

@@ -0,0 +1,66 @@
/**
* 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 { render, screen } from 'spec/helpers/testing-library';
import {
RoleNameField,
PermissionsField,
UsersField,
GroupsField,
} from './RoleFormItems';
jest.mock('./utils', () => ({
fetchPermissionOptions: jest.fn(),
fetchGroupOptions: jest.fn(),
}));
jest.mock('../groups/utils', () => ({
fetchUserOptions: jest.fn(),
}));
const addDangerToast = jest.fn();
test('RoleNameField renders label and input', () => {
render(<RoleNameField />);
expect(screen.getByText('Role Name')).toBeInTheDocument();
expect(screen.getByTestId('role-name-input')).toBeInTheDocument();
});
test('PermissionsField renders label and select', () => {
render(<PermissionsField addDangerToast={addDangerToast} />);
expect(screen.getByText('Permissions')).toBeInTheDocument();
expect(screen.getByTestId('permissions-select')).toBeInTheDocument();
});
test('PermissionsField renders loading state', () => {
render(<PermissionsField addDangerToast={addDangerToast} loading />);
expect(screen.getByText('Permissions')).toBeInTheDocument();
expect(screen.getByTestId('permissions-select')).toBeInTheDocument();
});
test('UsersField renders label and select', () => {
render(<UsersField addDangerToast={addDangerToast} loading={false} />);
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByTestId('roles-select')).toBeInTheDocument();
});
test('GroupsField renders label and select', () => {
render(<GroupsField addDangerToast={addDangerToast} />);
expect(screen.getByText('Groups')).toBeInTheDocument();
expect(screen.getByTestId('groups-select')).toBeInTheDocument();
});

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback } from 'react';
import { FormItem, Input, AsyncSelect } from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation';
import { fetchUserOptions } from '../groups/utils';
@@ -44,51 +45,69 @@ export const RoleNameField = () => (
export const PermissionsField = ({
addDangerToast,
loading = false,
}: AsyncOptionsFieldProps) => (
<FormItem name="rolePermissions" label={t('Permissions')}>
<AsyncSelect
mode="multiple"
name="rolePermissions"
placeholder={t('Select permissions')}
options={(filterValue, page, pageSize) =>
fetchPermissionOptions(filterValue, page, pageSize, addDangerToast)
}
loading={loading}
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
data-test="permissions-select"
/>
</FormItem>
);
}: AsyncOptionsFieldProps) => {
const options = useCallback(
(filterValue: string, page: number, pageSize: number) =>
fetchPermissionOptions(filterValue, page, pageSize, addDangerToast),
[addDangerToast],
);
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => (
<FormItem name="roleUsers" label={t('Users')}>
<AsyncSelect
name="roleUsers"
mode="multiple"
placeholder={t('Select users')}
options={(filterValue, page, pageSize) =>
fetchUserOptions(filterValue, page, pageSize, addDangerToast)
}
loading={loading}
data-test="roles-select"
/>
</FormItem>
);
return (
<FormItem name="rolePermissions" label={t('Permissions')}>
<AsyncSelect
mode="multiple"
name="rolePermissions"
placeholder={t('Select permissions')}
options={options}
loading={loading}
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
data-test="permissions-select"
/>
</FormItem>
);
};
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => {
const options = useCallback(
(filterValue: string, page: number, pageSize: number) =>
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
[addDangerToast],
);
return (
<FormItem name="roleUsers" label={t('Users')}>
<AsyncSelect
name="roleUsers"
mode="multiple"
placeholder={t('Select users')}
options={options}
loading={loading}
data-test="roles-select"
/>
</FormItem>
);
};
export const GroupsField = ({
addDangerToast,
loading = false,
}: AsyncOptionsFieldProps) => (
<FormItem name="roleGroups" label={t('Groups')}>
<AsyncSelect
mode="multiple"
name="roleGroups"
placeholder={t('Select groups')}
options={(filterValue, page, pageSize) =>
fetchGroupOptions(filterValue, page, pageSize, addDangerToast)
}
loading={loading}
data-test="groups-select"
/>
</FormItem>
);
}: AsyncOptionsFieldProps) => {
const options = useCallback(
(filterValue: string, page: number, pageSize: number) =>
fetchGroupOptions(filterValue, page, pageSize, addDangerToast),
[addDangerToast],
);
return (
<FormItem name="roleGroups" label={t('Groups')}>
<AsyncSelect
mode="multiple"
name="roleGroups"
placeholder={t('Select groups')}
options={options}
loading={loading}
data-test="groups-select"
/>
</FormItem>
);
};

View File

@@ -59,11 +59,15 @@ test('fetchPermissionOptions fetches all results on page 0 with large page_size'
expect(queries).toContainEqual({
page: 0,
page_size: 1000,
order_column: 'id',
order_direction: 'asc',
filters: [{ col: 'view_menu.name', opr: 'ct', value: 'dataset' }],
});
expect(queries).toContainEqual({
page: 0,
page_size: 1000,
order_column: 'id',
order_direction: 'asc',
filters: [{ col: 'permission.name', opr: 'ct', value: 'dataset' }],
});
@@ -125,6 +129,8 @@ test('fetchPermissionOptions makes single request when search term is empty', as
expect(rison.decode(queryString)).toEqual({
page: 0,
page_size: 100,
order_column: 'id',
order_direction: 'asc',
});
});
@@ -236,6 +242,8 @@ test('fetchGroupOptions sends filters array with search term', async () => {
expect(rison.decode(queryString)).toEqual({
page: 1,
page_size: 25,
order_column: 'name',
order_direction: 'asc',
filters: [{ col: 'name', opr: 'ct', value: 'eng' }],
});
expect(result).toEqual({
@@ -261,6 +269,8 @@ test('fetchGroupOptions omits filters when search term is empty', async () => {
expect(rison.decode(queryString)).toEqual({
page: 0,
page_size: 100,
order_column: 'name',
order_direction: 'asc',
});
});

View File

@@ -94,11 +94,14 @@ const fetchPermissionPageRaw = async (queryParams: Record<string, unknown>) => {
const fetchAllPermissionPages = async (
filters: Record<string, unknown>[],
): Promise<SelectOption[]> => {
const page0 = await fetchPermissionPageRaw({
page: 0,
const baseQuery = {
page_size: PAGE_SIZE,
order_column: 'id',
order_direction: 'asc',
filters,
});
};
const page0 = await fetchPermissionPageRaw({ ...baseQuery, page: 0 });
if (page0.data.length === 0 || page0.data.length >= page0.totalCount) {
return page0.data;
}
@@ -113,11 +116,7 @@ const fetchAllPermissionPages = async (
const batchEnd = Math.min(batch + CONCURRENCY_LIMIT, totalPages);
const batchResults = await Promise.all(
Array.from({ length: batchEnd - batch }, (_, i) =>
fetchPermissionPageRaw({
page: batch + i,
page_size: PAGE_SIZE,
filters,
}),
fetchPermissionPageRaw({ ...baseQuery, page: batch + i }),
),
);
for (const r of batchResults) {
@@ -138,7 +137,12 @@ export const fetchPermissionOptions = async (
) => {
if (!filterValue) {
try {
return await fetchPermissionPageRaw({ page, page_size: pageSize });
return await fetchPermissionPageRaw({
page,
page_size: pageSize,
order_column: 'id',
order_direction: 'asc',
});
} catch {
addDangerToast(t('There was an error while fetching permissions'));
return { data: [], totalCount: 0 };
@@ -193,6 +197,8 @@ export const fetchGroupOptions = async (
const query = rison.encode({
page,
page_size: pageSize,
order_column: 'name',
order_direction: 'asc',
...(filterValue
? { filters: [{ col: 'name', opr: 'ct', value: filterValue }] }
: {}),

View File

@@ -6,7 +6,7 @@
* 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
* 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
@@ -36,80 +36,77 @@ const mockedProps = {
resourceName: 'dashboard',
};
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('BulkTagModal', () => {
afterEach(() => {
fetchMock.clearHistory().removeRoutes();
jest.clearAllMocks();
afterEach(() => {
fetchMock.clearHistory().removeRoutes();
jest.clearAllMocks();
});
test('should render', () => {
const { container } = render(<BulkTagModal {...mockedProps} />);
expect(container).toBeInTheDocument();
});
test('renders the correct title and message', () => {
render(<BulkTagModal {...mockedProps} />);
expect(
screen.getByText(/you are adding tags to 2 dashboards/i),
).toBeInTheDocument();
expect(screen.getByText('Bulk tag')).toBeInTheDocument();
});
test('renders tags input field', async () => {
render(<BulkTagModal {...mockedProps} />);
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
expect(tagsInput).toBeInTheDocument();
});
test('calls onHide when the Cancel button is clicked', () => {
render(<BulkTagModal {...mockedProps} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockedProps.onHide).toHaveBeenCalled();
});
test('submits the selected tags and shows success toast', async () => {
fetchMock.post('glob:*/api/v1/tag/bulk_create', {
result: {
objects_tagged: [1, 2],
objects_skipped: [],
},
});
test('should render', () => {
const { container } = render(<BulkTagModal {...mockedProps} />);
expect(container).toBeInTheDocument();
render(<BulkTagModal {...mockedProps} />);
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(mockedProps.addSuccessToast).toHaveBeenCalledWith(
'Tagged 2 dashboards',
);
});
test('renders the correct title and message', () => {
render(<BulkTagModal {...mockedProps} />);
expect(
screen.getByText(/you are adding tags to 2 dashboards/i),
).toBeInTheDocument();
expect(screen.getByText('Bulk tag')).toBeInTheDocument();
});
expect(mockedProps.refreshData).toHaveBeenCalled();
expect(mockedProps.onHide).toHaveBeenCalled();
});
test('renders tags input field', async () => {
render(<BulkTagModal {...mockedProps} />);
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
expect(tagsInput).toBeInTheDocument();
});
test('handles API errors gracefully', async () => {
fetchMock.post('glob:*/api/v1/tag/bulk_create', 500);
test('calls onHide when the Cancel button is clicked', () => {
render(<BulkTagModal {...mockedProps} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockedProps.onHide).toHaveBeenCalled();
});
render(<BulkTagModal {...mockedProps} />);
test('submits the selected tags and shows success toast', async () => {
fetchMock.post('glob:*/api/v1/tag/bulk_create', {
result: {
objects_tagged: [1, 2],
objects_skipped: [],
},
});
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
render(<BulkTagModal {...mockedProps} />);
fireEvent.click(screen.getByText('Save'));
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(mockedProps.addSuccessToast).toHaveBeenCalledWith(
'Tagged 2 dashboards',
);
});
expect(mockedProps.refreshData).toHaveBeenCalled();
expect(mockedProps.onHide).toHaveBeenCalled();
});
test('handles API errors gracefully', async () => {
fetchMock.post('glob:*/api/v1/tag/bulk_create', 500);
render(<BulkTagModal {...mockedProps} />);
const tagsInput = await screen.findByRole('combobox', { name: /tags/i });
fireEvent.change(tagsInput, { target: { value: 'Test Tag' } });
fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(mockedProps.addDangerToast).toHaveBeenCalledWith(
'Failed to tag items',
);
});
await waitFor(() => {
expect(mockedProps.addDangerToast).toHaveBeenCalledWith(
'Failed to tag items',
);
});
});

View File

@@ -1658,3 +1658,42 @@ test('renders standard Select dropdown when operatorType is Exact', () => {
expect(screen.getAllByRole('combobox').length).toBeGreaterThan(0);
});
test('renders dashboard select dropdown popup under document body', async () => {
jest.useFakeTimers({ advanceTimers: true });
render(<SelectFilterPlugin {...buildSelectFilterProps()} />, {
useRedux: true,
initialState: {
nativeFilters: {
filters: { 'test-filter': { name: 'Test Filter' } },
},
dataMask: {
'test-filter': {
extraFormData: {
filters: [{ col: 'gender', op: 'IN', val: ['boy'] }],
},
filterState: {
value: ['boy'],
label: 'boy',
excludeFilterValues: true,
},
},
},
},
});
const [filterSelect] = screen.getAllByRole('combobox');
userEvent.click(filterSelect);
let dropdown: Element | undefined;
await waitFor(() => {
dropdown = Array.from(
document.querySelectorAll('.ant-select-dropdown'),
).find(
element => !element.classList.contains('ant-select-dropdown-hidden'),
);
expect(dropdown).toBeDefined();
});
expect(dropdown?.parentElement).toBe(document.body);
});

View File

@@ -539,6 +539,19 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
[debouncedLikeChange],
);
const getSelectPopupContainer = useCallback(
(trigger: HTMLElement) => {
if (showOverflow) {
return (parentRef?.current as HTMLElement) || document.body;
}
if (appSection === AppSection.FilterConfigModal) {
return (trigger?.parentNode as HTMLElement) || document.body;
}
return document.body;
},
[appSection, parentRef, showOverflow],
);
const likeInputPlaceholder = useMemo(() => {
switch (operatorType) {
case SelectFilterOperatorType.Contains:
@@ -571,6 +584,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
{ value: 'false', label: t('is') },
]}
onChange={handleExclusionToggle}
getPopupContainer={getSelectPopupContainer}
/>
)}
{isLikeOperator ? (
@@ -595,12 +609,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
allowSelectAll={!searchAllOptions}
value={multiSelect ? filterState.value || [] : filterState.value}
disabled={isDisabled}
getPopupContainer={
showOverflow
? () => (parentRef?.current as HTMLElement) || document.body
: (trigger: HTMLElement) =>
(trigger?.parentNode as HTMLElement) || document.body
}
getPopupContainer={getSelectPopupContainer}
showSearch={showSearch}
mode={multiSelect ? 'multiple' : 'single'}
placeholder={placeholderText}

View File

@@ -38,7 +38,6 @@ const TestComponent = (props: ThemeSubMenuProps) => {
return <Menu items={[menuItem]} />;
};
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('useThemeMenuItems', () => {
const defaultProps = {
allowOSPreference: true,

View File

@@ -7,7 +7,7 @@
* "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
* 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
@@ -27,133 +27,127 @@ import {
ColumnDefinition,
} from 'src/utils/common';
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('utils/common', () => {
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('optionFromValue', () => {
test('converts values as expected', () => {
expect(optionFromValue(false)).toEqual({
value: false,
label: FALSE_STRING,
});
expect(optionFromValue(true)).toEqual({
value: true,
label: TRUE_STRING,
});
expect(optionFromValue(null)).toEqual({
value: NULL_STRING,
label: NULL_STRING,
});
expect(optionFromValue('')).toEqual({
value: '',
label: '<empty string>',
});
expect(optionFromValue('foo')).toEqual({ value: 'foo', label: 'foo' });
expect(optionFromValue(5)).toEqual({ value: 5, label: '5' });
});
test('converts values as expected', () => {
expect(optionFromValue(false)).toEqual({
value: false,
label: FALSE_STRING,
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('prepareCopyToClipboardTabularData', () => {
test('converts empty array', () => {
const data: TabularDataRow[] = [];
const columns: string[] = [];
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual('');
});
test('converts non empty array', () => {
const data: TabularDataRow[] = [
{ column1: 'lorem', column2: 'ipsum' },
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
];
const columns: string[] = ['column1', 'column2', 'column3'];
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
'column1\tcolumn2\tcolumn3\nlorem\tipsum\t\ndolor\tsit\tamet\n',
);
});
test('includes 0 values and handle column objects', () => {
const data: TabularDataRow[] = [
{ column1: 0, column2: 0 },
{ column1: 1, column2: -1, 0: 0 },
];
const columns: ColumnDefinition[] = [
{ name: 'column1' },
{ name: 'column2' },
{ name: '0' },
];
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
'column1\tcolumn2\t0\n0\t0\t\n1\t-1\t0\n',
);
});
expect(optionFromValue(true)).toEqual({
value: true,
label: TRUE_STRING,
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('applyFormattingToTabularData', () => {
test('does not mutate empty array', () => {
const data: TabularDataRow[] = [];
expect(applyFormattingToTabularData(data, [])).toEqual(data);
});
test('does not mutate array without temporal column', () => {
const data: TabularDataRow[] = [
{ column1: 'lorem', column2: 'ipsum' },
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
];
expect(applyFormattingToTabularData(data, [])).toEqual(data);
});
test('changes formatting of columns selected for formatting', () => {
const originalData: TabularDataRow[] = [
{
__timestamp: null,
column1: 'lorem',
column2: 1590014060000,
column3: 1507680000000,
},
{
__timestamp: 0,
column1: 'ipsum',
column2: 1590075817000,
column3: 1513641600000,
},
{
__timestamp: 1594285437771,
column1: 'dolor',
column2: 1591062977000,
column3: 1516924800000,
},
{
__timestamp: 1594285441675,
column1: 'sit',
column2: 1591397351000,
column3: 1518566400000,
},
];
const timeFormattedColumns: string[] = ['__timestamp', 'column3'];
const expectedData: TabularDataRow[] = [
{
__timestamp: null,
column1: 'lorem',
column2: 1590014060000,
column3: '2017-10-11 00:00:00',
},
{
__timestamp: '1970-01-01 00:00:00',
column1: 'ipsum',
column2: 1590075817000,
column3: '2017-12-19 00:00:00',
},
{
__timestamp: '2020-07-09 09:03:57',
column1: 'dolor',
column2: 1591062977000,
column3: '2018-01-26 00:00:00',
},
{
__timestamp: '2020-07-09 09:04:01',
column1: 'sit',
column2: 1591397351000,
column3: '2018-02-14 00:00:00',
},
];
expect(
applyFormattingToTabularData(originalData, timeFormattedColumns),
).toEqual(expectedData);
});
expect(optionFromValue(null)).toEqual({
value: NULL_STRING,
label: NULL_STRING,
});
expect(optionFromValue('')).toEqual({
value: '',
label: '<empty string>',
});
expect(optionFromValue('foo')).toEqual({ value: 'foo', label: 'foo' });
expect(optionFromValue(5)).toEqual({ value: 5, label: '5' });
});
test('converts empty array', () => {
const data: TabularDataRow[] = [];
const columns: string[] = [];
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual('');
});
test('converts non empty array', () => {
const data: TabularDataRow[] = [
{ column1: 'lorem', column2: 'ipsum' },
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
];
const columns: string[] = ['column1', 'column2', 'column3'];
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
'column1\tcolumn2\tcolumn3\nlorem\tipsum\t\ndolor\tsit\tamet\n',
);
});
test('includes 0 values and handle column objects', () => {
const data: TabularDataRow[] = [
{ column1: 0, column2: 0 },
{ column1: 1, column2: -1, 0: 0 },
];
const columns: ColumnDefinition[] = [
{ name: 'column1' },
{ name: 'column2' },
{ name: '0' },
];
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
'column1\tcolumn2\t0\n0\t0\t\n1\t-1\t0\n',
);
});
test('does not mutate empty array', () => {
const data: TabularDataRow[] = [];
expect(applyFormattingToTabularData(data, [])).toEqual(data);
});
test('does not mutate array without temporal column', () => {
const data: TabularDataRow[] = [
{ column1: 'lorem', column2: 'ipsum' },
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
];
expect(applyFormattingToTabularData(data, [])).toEqual(data);
});
test('changes formatting of columns selected for formatting', () => {
const originalData: TabularDataRow[] = [
{
__timestamp: null,
column1: 'lorem',
column2: 1590014060000,
column3: 1507680000000,
},
{
__timestamp: 0,
column1: 'ipsum',
column2: 1590075817000,
column3: 1513641600000,
},
{
__timestamp: 1594285437771,
column1: 'dolor',
column2: 1591062977000,
column3: 1516924800000,
},
{
__timestamp: 1594285441675,
column1: 'sit',
column2: 1591397351000,
column3: 1518566400000,
},
];
const timeFormattedColumns: string[] = ['__timestamp', 'column3'];
const expectedData: TabularDataRow[] = [
{
__timestamp: null,
column1: 'lorem',
column2: 1590014060000,
column3: '2017-10-11 00:00:00',
},
{
__timestamp: '1970-01-01 00:00:00',
column1: 'ipsum',
column2: 1590075817000,
column3: '2017-12-19 00:00:00',
},
{
__timestamp: '2020-07-09 09:03:57',
column1: 'dolor',
column2: 1591062977000,
column3: '2018-01-26 00:00:00',
},
{
__timestamp: '2020-07-09 09:04:01',
column1: 'sit',
column2: 1591397351000,
column3: '2018-02-14 00:00:00',
},
];
expect(
applyFormattingToTabularData(originalData, timeFormattedColumns),
).toEqual(expectedData);
});

View File

@@ -17,8 +17,6 @@
# isort:skip_file
import logging
import random
import string
import uuid as uuid_module
from typing import Any, Optional, Callable
from collections.abc import Iterator
@@ -50,13 +48,6 @@ DEFAULT_CHART_HEIGHT = 50
DEFAULT_CHART_WIDTH = 4
def suffix(length: int = 8) -> str:
return "".join(
random.SystemRandom().choice(string.ascii_uppercase + string.digits)
for _ in range(length)
)
def get_default_position(title: str) -> dict[str, Any]:
return {
"DASHBOARD_VERSION_KEY": "v2",
@@ -72,12 +63,12 @@ def get_default_position(title: str) -> dict[str, Any]:
def append_charts(position: dict[str, Any], charts: set[Slice]) -> dict[str, Any]:
chart_hashes = [f"CHART-{suffix()}" for _ in charts]
chart_hashes = [f"CHART-{str(chart.uuid)}" for chart in charts]
# if we have ROOT_ID/GRID_ID, append orphan charts to a new row inside the grid
row_hash = None
if "ROOT_ID" in position and "GRID_ID" in position["ROOT_ID"]["children"]:
row_hash = f"ROW-N-{suffix()}"
row_hash = f"ROW-N-{len(position['GRID_ID']['children'])}"
position["GRID_ID"]["children"].append(row_hash)
position[row_hash] = {
"children": chart_hashes,

View File

@@ -408,20 +408,30 @@ AUTH_PASSWORD_COMMON_BLOCKLIST: list[str] = []
APP_NAME = "Superset"
# Specify the App icon
# NOTE: This variable is used to populate THEME_DEFAULT. If you override this in
# superset_config.py, you must also override THEME_DEFAULT to see the change,
# or set THEME_DEFAULT["token"]["brandLogoUrl"] directly.
APP_ICON = "/static/assets/images/superset-logo-horiz.png"
# Specify where clicking the logo would take the user'
# Specify where clicking the logo would take the user
# Default value of None will take you to '/superset/welcome'
# You can also specify a relative URL e.g. '/superset/welcome' or '/dashboards/list'
# or you can specify a full URL e.g. 'https://foo.bar'
# NOTE: Overriding this in superset_config.py automatically updates the logo link
# (THEME_DEFAULT["token"]["brandLogoHref"]); see sync_theme_logo_href below.
LOGO_TARGET_PATH = None
# Specify tooltip that should appear when hovering over the App Icon/Logo
# NOTE: This variable is deprecated and not used in the new theme system.
LOGO_TOOLTIP = ""
# Specify any text that should appear to the right of the logo
# NOTE: This variable is deprecated and not used in the new theme system.
LOGO_RIGHT_TEXT: Callable[[], str] | str = ""
# APP_ICON_WIDTH is deprecated.
# Use THEME_DEFAULT["token"]["brandLogoHeight"] instead (default: "24px").
# Enables SWAGGER UI for superset openapi spec
# ex: http://localhost:8080/swagger/v1
FAB_API_SWAGGER_UI = True
@@ -1008,9 +1018,10 @@ THEME_DEFAULT: Theme = {
"brandLogoAlt": "Apache Superset",
"brandLogoUrl": APP_ICON,
"brandLogoMargin": "18px 0",
"brandLogoHref": "/",
"brandLogoHref": LOGO_TARGET_PATH or "/",
"brandLogoHeight": "24px",
# Spinner
# Spinner - Set this to use a custom GIF/image loader
# "brandSpinnerUrl": "/static/assets/images/loading.gif",
"brandSpinnerUrl": None,
"brandSpinnerSvg": None,
# Default colors
@@ -1052,6 +1063,22 @@ THEME_DARK: Optional[Theme] = {
"algorithm": "dark",
}
def sync_theme_logo_href(
theme: Optional[Theme], logo_target_path: Optional[str]
) -> None:
"""
Apply ``LOGO_TARGET_PATH`` to a theme's ``brandLogoHref`` token.
``THEME_DEFAULT`` / ``THEME_DARK`` are built above, before ``superset_config.py``
and environment overrides are applied at the bottom of this module. This is
re-run after those overrides so that setting only ``LOGO_TARGET_PATH`` updates
the logo link without also having to override the whole theme object.
"""
if theme and logo_target_path and isinstance(theme.get("token"), dict):
theme["token"]["brandLogoHref"] = logo_target_path
# Theme behavior and user preference settings
# To force a single theme on all users, set THEME_DARK = None
# When both THEME_DEFAULT and THEME_DARK are defined:
@@ -2831,3 +2858,9 @@ for env_var in ENV_VAR_KEYS:
if env_var in os.environ:
config_var = env_var.replace("SUPERSET__", "")
globals()[config_var] = os.environ[env_var]
# THEME_DEFAULT / THEME_DARK are defined before the overrides above are applied,
# so re-sync the logo link from the final LOGO_TARGET_PATH value here. This lets
# users set just LOGO_TARGET_PATH without also overriding the whole theme.
sync_theme_logo_href(THEME_DEFAULT, LOGO_TARGET_PATH)
sync_theme_logo_href(THEME_DARK, LOGO_TARGET_PATH)

View File

@@ -512,7 +512,10 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
database, catalog=table.catalog, schema=table.schema
) as engine:
client = cls._get_client(engine, database)
bq_table = client.get_table(f"{table.schema}.{table.table}")
table_ref = f"{table.schema}.{table.table}"
if table.catalog:
table_ref = f"{table.catalog}.{table_ref}"
bq_table = client.get_table(table_ref)
if bq_table.time_partitioning:
return bq_table.time_partitioning.field

View File

@@ -0,0 +1,92 @@
# 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.
"""
Pydantic schemas for explore-related MCP tool outputs.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
from superset.mcp_service.common.error_schemas import ChartGenerationError
class GenerateExploreLinkResponse(BaseModel):
"""
Output schema for the generate_explore_link tool.
On success, ``url`` is a fully-qualified Superset Explore URL that the
user can open immediately, and ``form_data_key`` can be used to
reconstruct or share the same configuration. On failure, ``url`` is
empty and ``error`` is a ``ChartGenerationError``; its ``error_type``
distinguishes ``dataset_not_found``, ``permission_denied``,
``validation_error``, and ``generation_failed`` so callers can branch
on failure mode without parsing free-text messages.
"""
url: str = Field(
...,
description=(
"Explore URL — open in a browser to view the interactive chart. "
"Empty string on failure."
),
)
form_data: dict[str, Any] = Field(
default_factory=dict,
description="Raw Superset form_data dict that was encoded into the URL.",
)
permalink_key: str | None = Field(
None,
description=(
"Durable permalink key for the generated Explore URL, when one "
"was created. Prefer this over ``form_data_key`` for sharing; it "
"survives cache eviction. Null on failure or when only an "
"ephemeral form_data key is available."
),
)
form_data_key: str | None = Field(
None,
description=(
"Short, ephemeral cache key that represents this form_data "
"configuration. Populated only when no ``permalink_key`` is "
"available. Can be passed to the Explore UI as ?form_data_key=<key>."
),
)
chart_type_label: str | None = Field(
None,
description=(
"Human-readable label for the resulting chart type "
"(e.g. 'table chart', 'interactive table chart'). "
"Null on failure or when the viz_type has no specific label."
),
)
error: ChartGenerationError | None = Field(
None,
description=(
"Structured ChartGenerationError when generation fails, else "
"null. Branch on error.error_type to handle specific failure "
"modes (dataset_not_found, permission_denied, validation_error, "
"generation_failed)."
),
)
success: bool = Field(
True,
description="True when a valid URL was produced, False on any error.",
)

View File

@@ -23,16 +23,14 @@ chart configuration.
"""
import logging
from typing import Any, Dict
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.daos.dataset import DatasetDAO
from superset.extensions import event_logger
from superset.mcp_service.auth import has_dataset_access
from superset.mcp_service.chart.chart_helpers import (
extract_form_data_key_from_url,
)
from superset.mcp_service.chart.chart_helpers import extract_form_data_key_from_url
from superset.mcp_service.chart.chart_utils import (
generate_explore_link as generate_url,
get_table_chart_type_label,
@@ -42,8 +40,12 @@ from superset.mcp_service.chart.compile import validate_and_compile
from superset.mcp_service.chart.schemas import (
GenerateExploreLinkRequest,
)
from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
from superset.mcp_service.common.error_schemas import ChartGenerationError
from superset.mcp_service.explore.schemas import GenerateExploreLinkResponse
from superset.mcp_service.utils.url_utils import (
extract_permalink_key_from_url,
get_superset_base_url,
)
logger = logging.getLogger(__name__)
@@ -60,7 +62,7 @@ logger = logging.getLogger(__name__)
)
async def generate_explore_link(
request: GenerateExploreLinkRequest, ctx: Context
) -> Dict[str, Any]:
) -> GenerateExploreLinkResponse:
"""Generate explore URL for interactive visualization.
PREFERRED TOOL for most visualization requests.
@@ -118,8 +120,6 @@ async def generate_explore_link(
try:
await ctx.report_progress(1, 4, "Validating dataset exists")
with event_logger.log_context(action="mcp.generate_explore_link.dataset_check"):
from superset.daos.dataset import DatasetDAO
dataset = None
if isinstance(request.dataset_id, int) or (
isinstance(request.dataset_id, str) and request.dataset_id.isdigit()
@@ -137,17 +137,28 @@ async def generate_explore_link(
await ctx.warning(
"Dataset not found: dataset_id=%s" % (request.dataset_id,)
)
return {
"url": "",
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": (
f"Dataset not found: {request.dataset_id}. "
"Use list_datasets to find valid dataset IDs."
return GenerateExploreLinkResponse(
url="",
form_data={},
permalink_key=None,
form_data_key=None,
chart_type_label=None,
error=ChartGenerationError(
error_type="dataset_not_found",
error_code="MCP_EXPLORE_DATASET_NOT_FOUND",
message=f"Dataset not found: {request.dataset_id}.",
details=(
f"No dataset found with identifier "
f"'{request.dataset_id}'. Use list_datasets to "
"find valid dataset IDs."
),
suggestions=[
"Verify the dataset ID or UUID is correct",
"Use the list_datasets tool to find available datasets",
],
),
}
success=False,
)
if not has_dataset_access(dataset):
logger.warning(
@@ -157,24 +168,39 @@ async def generate_explore_link(
await ctx.warning(
"Dataset access denied: dataset_id=%s" % (request.dataset_id,)
)
return {
"url": "",
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": (
f"Dataset not found: {request.dataset_id}. "
"Use list_datasets to find valid dataset IDs."
# User-facing message stays generic to avoid leaking dataset
# existence; error_type lets programmatic callers distinguish.
return GenerateExploreLinkResponse(
url="",
form_data={},
permalink_key=None,
form_data_key=None,
chart_type_label=None,
error=ChartGenerationError(
error_type="permission_denied",
# Same code as the not-found path: the user-visible
# message is intentionally indistinguishable so
# access policy isn't disclosed; ``error_type`` is
# the programmatic distinguisher.
error_code="MCP_EXPLORE_DATASET_NOT_FOUND",
message=f"Dataset not found: {request.dataset_id}.",
details=(
f"No dataset found with identifier "
f"'{request.dataset_id}'. Use list_datasets to "
"find valid dataset IDs."
),
suggestions=[
"Check that you have access to this dataset",
"Use the list_datasets tool to find available datasets",
],
),
}
success=False,
)
# When no config is provided, return a default explore URL that opens
# the dataset in Superset without a preconfigured chart.
if request.config is None:
await ctx.report_progress(4, 4, "URL generation complete")
from superset.mcp_service.utils.url_utils import get_superset_base_url
base_url = get_superset_base_url()
default_url = (
f"{base_url}/explore/?datasource_type=table&datasource_id={dataset.id}"
@@ -182,14 +208,15 @@ async def generate_explore_link(
await ctx.info(
"Default explore link generated: dataset_id=%s" % (request.dataset_id,)
)
return {
"url": default_url,
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": None,
}
return GenerateExploreLinkResponse(
url=default_url,
form_data={},
permalink_key=None,
form_data_key=None,
chart_type_label=None,
error=None,
success=True,
)
await ctx.report_progress(2, 4, "Converting configuration to form data")
with event_logger.log_context(action="mcp.generate_explore_link.form_data"):
@@ -199,14 +226,28 @@ async def generate_explore_link(
# Normalize column names to match canonical dataset column names
# This fixes case sensitivity issues (e.g., 'order_date' vs 'OrderDate')
try:
from superset.mcp_service.chart.validation.dataset_validator import (
DatasetValidator,
)
normalized_config = DatasetValidator.normalize_column_names(
config, request.dataset_id
)
except (ImportError, AttributeError, KeyError, ValueError, TypeError):
except (
ImportError,
AttributeError,
KeyError,
ValueError,
TypeError,
) as norm_err:
logger.warning(
"Column normalization failed for dataset_id=%s; falling back "
"to caller-supplied config. %s: %s",
request.dataset_id,
type(norm_err).__name__,
norm_err,
)
await ctx.warning(
"Column normalization failed for dataset_id=%s; using config "
"as-supplied. Chart may behave unexpectedly if column names "
"differ in case." % (request.dataset_id,)
)
normalized_config = config
# Map config to form_data using shared utilities
@@ -242,25 +283,24 @@ async def generate_explore_link(
await ctx.warning(
"Explore link validation failed: error=%s" % (compile_result.error,)
)
error_payload: Dict[str, Any]
if compile_result.error_obj is not None:
error_payload = compile_result.error_obj.model_dump()
error_payload = compile_result.error_obj
else:
error_payload = {
"error_type": "validation_error",
"message": "Explore link validation failed",
"details": compile_result.error or "",
"error_code": compile_result.error_code,
"suggestions": [],
}
return {
"url": "",
"form_data": form_data,
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": error_payload,
}
error_payload = ChartGenerationError(
error_type="validation_error",
message="Explore link validation failed",
details=compile_result.error or "",
error_code=compile_result.error_code,
)
return GenerateExploreLinkResponse(
url="",
form_data=form_data,
permalink_key=None,
form_data_key=None,
chart_type_label=None,
error=error_payload,
success=False,
)
await ctx.report_progress(3, 4, "Generating explore URL")
with event_logger.log_context(
@@ -284,14 +324,15 @@ async def generate_explore_link(
% (len(explore_url or ""), request.dataset_id, permalink_key, form_data_key)
)
return {
"url": explore_url,
"form_data": form_data,
"permalink_key": permalink_key,
"form_data_key": form_data_key,
"chart_type_label": get_table_chart_type_label(form_data.get("viz_type")),
"error": None,
}
return GenerateExploreLinkResponse(
url=explore_url,
form_data=form_data,
permalink_key=permalink_key,
form_data_key=form_data_key,
chart_type_label=get_table_chart_type_label(form_data.get("viz_type")),
error=None,
success=True,
)
except Exception as e:
await ctx.error(
@@ -303,11 +344,23 @@ async def generate_explore_link(
str(e),
)
)
return {
"url": "",
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": f"Failed to generate explore link: {str(e)}",
}
# ``details`` intentionally omits ``str(e)`` so internal info
# (file paths, schema names) isn't echoed to the MCP response.
# The raw exception is already captured in the server-side log
# above via ``ctx.error``.
return GenerateExploreLinkResponse(
url="",
form_data={},
permalink_key=None,
form_data_key=None,
chart_type_label=None,
error=ChartGenerationError(
error_type="generation_failed",
error_code="MCP_EXPLORE_GENERATION_FAILED",
message="Failed to generate explore link",
details=(
"An unexpected error occurred; check server logs for details."
),
),
success=False,
)

View File

@@ -1626,6 +1626,32 @@ class DeckGLMultiLayer(BaseViz):
is_timeseries = False
credits = '<a href="https://uber.github.io/deck.gl/">deck.gl</a>'
@staticmethod
def _merge_filter_metadata(
*filter_groups: list[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Merge multiple filter metadata lists, de-duplicating identical entries.
Used to combine the applied/rejected filter metadata reported by each
child layer into a single list for the multi-layer chart payload.
"""
merged_filters: list[dict[str, Any]] = []
seen_filters: set[str] = set()
for filters in filter_groups:
for filter_metadata in filters or []:
if not isinstance(filter_metadata, dict):
continue
cache_key = json.dumps(filter_metadata, sort_keys=True)
if cache_key in seen_filters:
continue
merged_filters.append(filter_metadata)
seen_filters.add(cache_key)
return merged_filters
@deprecated(deprecated_in="3.0")
def query_obj(self) -> QueryObjectDict:
return {}
@@ -1726,6 +1752,8 @@ class DeckGLMultiLayer(BaseViz):
slices = db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
features: dict[str, list[Any]] = {}
self.applied_filters = []
self.rejected_filters = []
for layer_index, slc in enumerate(slices):
form_data = slc.form_data
@@ -1738,6 +1766,15 @@ class DeckGLMultiLayer(BaseViz):
viz_instance = viz_class(datasource=slc.datasource, form_data=form_data)
payload = viz_instance.get_payload()
if payload:
self.applied_filters = self._merge_filter_metadata(
self.applied_filters,
payload.get("applied_filters"),
)
self.rejected_filters = self._merge_filter_metadata(
self.rejected_filters,
payload.get("rejected_filters"),
)
if (
payload
@@ -1755,6 +1792,25 @@ class DeckGLMultiLayer(BaseViz):
"slices": [slc.data for slc in slices if slc.data is not None],
}
@deprecated(deprecated_in="3.0")
def get_payload(self, query_obj: QueryObjectDict | None = None) -> VizPayload:
"""Extend the base payload with merged child-layer filter metadata.
The applied/rejected filter metadata collected from each sub-slice in
``get_data`` is merged into the base payload so dashboard filter badges
reflect the filters applied across all layers.
"""
payload = super().get_payload(query_obj)
payload["applied_filters"] = self._merge_filter_metadata(
payload.get("applied_filters"),
self.applied_filters,
)
payload["rejected_filters"] = self._merge_filter_metadata(
payload.get("rejected_filters"),
self.rejected_filters,
)
return payload
class BaseDeckGLViz(BaseViz):
"""Base class for deck.gl visualizations"""

View File

@@ -14,7 +14,6 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import itertools
from unittest.mock import MagicMock, patch # noqa: F401
import pytest
@@ -348,22 +347,21 @@ class TestExportDashboardsCommand(SupersetTestCase):
}
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@patch("superset.commands.dashboard.export.suffix")
def test_append_charts(self, mock_suffix):
def test_append_charts(self):
"""Test that orphaned charts are added to the dashboard position"""
# return deterministic IDs
mock_suffix.side_effect = (str(i) for i in itertools.count(1))
# IDs are deterministic: charts are keyed by their UUID and rows are
# numbered by their position within the grid.
position = get_default_position("example")
chart_1 = (
db.session.query(Slice).filter_by(slice_name="World's Population").one()
)
chart_1_key = f"CHART-{chart_1.uuid}"
new_position = append_charts(position, {chart_1})
assert new_position == {
"DASHBOARD_VERSION_KEY": "v2",
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
"GRID_ID": {
"children": ["ROW-N-2"],
"children": ["ROW-N-0"],
"id": "GRID_ID",
"parents": ["ROOT_ID"],
"type": "GRID",
@@ -373,16 +371,16 @@ class TestExportDashboardsCommand(SupersetTestCase):
"meta": {"text": "example"},
"type": "HEADER",
},
"ROW-N-2": {
"children": ["CHART-1"],
"id": "ROW-N-2",
"ROW-N-0": {
"children": [chart_1_key],
"id": "ROW-N-0",
"meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"},
"type": "ROW",
"parents": ["ROOT_ID", "GRID_ID"],
},
"CHART-1": {
chart_1_key: {
"children": [],
"id": "CHART-1",
"id": chart_1_key,
"meta": {
"chartId": chart_1.id,
"height": 50,
@@ -391,19 +389,18 @@ class TestExportDashboardsCommand(SupersetTestCase):
"width": 4,
},
"type": "CHART",
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-2"],
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-0"],
},
}
chart_2 = (
db.session.query(Slice).filter_by(slice_name="World's Population").one()
)
chart_2 = db.session.query(Slice).filter_by(slice_name="Growth Rate").one()
chart_2_key = f"CHART-{chart_2.uuid}"
new_position = append_charts(new_position, {chart_2})
assert new_position == {
"DASHBOARD_VERSION_KEY": "v2",
"ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"},
"GRID_ID": {
"children": ["ROW-N-2", "ROW-N-4"],
"children": ["ROW-N-0", "ROW-N-1"],
"id": "GRID_ID",
"parents": ["ROOT_ID"],
"type": "GRID",
@@ -413,23 +410,23 @@ class TestExportDashboardsCommand(SupersetTestCase):
"meta": {"text": "example"},
"type": "HEADER",
},
"ROW-N-2": {
"children": ["CHART-1"],
"id": "ROW-N-2",
"ROW-N-0": {
"children": [chart_1_key],
"id": "ROW-N-0",
"meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"},
"type": "ROW",
"parents": ["ROOT_ID", "GRID_ID"],
},
"ROW-N-4": {
"children": ["CHART-3"],
"id": "ROW-N-4",
"ROW-N-1": {
"children": [chart_2_key],
"id": "ROW-N-1",
"meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"},
"type": "ROW",
"parents": ["ROOT_ID", "GRID_ID"],
},
"CHART-1": {
chart_1_key: {
"children": [],
"id": "CHART-1",
"id": chart_1_key,
"meta": {
"chartId": chart_1.id,
"height": 50,
@@ -438,29 +435,29 @@ class TestExportDashboardsCommand(SupersetTestCase):
"width": 4,
},
"type": "CHART",
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-2"],
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-0"],
},
"CHART-3": {
chart_2_key: {
"children": [],
"id": "CHART-3",
"id": chart_2_key,
"meta": {
"chartId": chart_2.id,
"height": 50,
"sliceName": "World's Population",
"sliceName": "Growth Rate",
"uuid": str(chart_2.uuid),
"width": 4,
},
"type": "CHART",
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-4"],
"parents": ["ROOT_ID", "GRID_ID", "ROW-N-1"],
},
}
position = {"DASHBOARD_VERSION_KEY": "v2"}
new_position = append_charts(position, [chart_1, chart_2])
assert new_position == {
"CHART-5": {
chart_1_key: {
"children": [],
"id": "CHART-5",
"id": chart_1_key,
"meta": {
"chartId": chart_1.id,
"height": 50,
@@ -470,13 +467,13 @@ class TestExportDashboardsCommand(SupersetTestCase):
},
"type": "CHART",
},
"CHART-6": {
chart_2_key: {
"children": [],
"id": "CHART-6",
"id": chart_2_key,
"meta": {
"chartId": chart_2.id,
"height": 50,
"sliceName": "World's Population",
"sliceName": "Growth Rate",
"uuid": str(chart_2.uuid),
"width": 4,
},

View File

@@ -27,7 +27,7 @@ import tests.integration_tests.test_app # noqa: F401
import superset.viz as viz
from flask import current_app
from superset.exceptions import QueryObjectValidationError, SpatialException
from superset.utils.core import DTTM_ALIAS
from superset.utils.core import DTTM_ALIAS, ExtraFiltersReasonType
from superset.utils.pandas_postprocessing.utils import FLAT_COLUMN_SEPARATOR
from tests.conftest import with_config
@@ -1849,6 +1849,79 @@ class TestDeckGLMultiLayer(SupersetTestCase):
assert len(result["slices"]) == 1
assert result["slices"][0] == slice_1.data
@with_config({"MAPBOX_API_KEY": "test_key"})
@patch("superset.viz.viz_types")
@patch("superset.db.session")
def test_get_payload_includes_subslice_filter_metadata(
self,
mock_db_session,
mock_viz_types,
):
"""Test deck.gl multi-layer payload includes child filter metadata."""
datasource = self.get_datasource_mock()
slice_1 = Mock()
slice_1.form_data = {"viz_type": "deck_scatter"}
slice_1.data = {"features": [{"type": "Feature"}]}
slice_1.datasource = datasource
slice_2 = Mock()
slice_2.form_data = {"viz_type": "deck_path"}
slice_2.data = {"features": [{"type": "Feature"}]}
slice_2.datasource = datasource
mock_db_session.query.return_value.filter.return_value.all.return_value = [
slice_1,
slice_2,
]
mock_scatter_viz_class = Mock()
mock_scatter_viz_instance = Mock()
mock_scatter_viz_instance.get_payload.return_value = {
"data": {"features": [{"id": 1}]},
"applied_filters": [{"column": "Latitude"}],
"rejected_filters": [],
}
mock_scatter_viz_class.return_value = mock_scatter_viz_instance
mock_path_viz_class = Mock()
mock_path_viz_instance = Mock()
mock_path_viz_instance.get_payload.return_value = {
"data": {"features": [{"id": 2}]},
"applied_filters": [
{"column": "Latitude"},
{"column": "Longitude"},
],
"rejected_filters": [
{
"column": "Country",
"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
},
],
}
mock_path_viz_class.return_value = mock_path_viz_instance
mock_viz_types.get.side_effect = lambda viz_type: {
"deck_scatter": mock_scatter_viz_class,
"deck_path": mock_path_viz_class,
}.get(viz_type)
test_viz = viz.DeckGLMultiLayer(datasource, {"deck_slices": [1, 2]})
test_viz.get_df_payload = Mock(return_value={"df": pd.DataFrame()})
result = test_viz.get_payload()
assert result["applied_filters"] == [
{"column": "Latitude"},
{"column": "Longitude"},
]
assert result["rejected_filters"] == [
{
"column": "Country",
"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
},
]
@with_config({"MAPBOX_API_KEY": "test_key"})
def test_get_data_empty_deck_slices(self):
"""Test get_data method with empty deck_slices."""

View File

@@ -312,3 +312,40 @@ def test_full_setting(
assert dttm_col.is_dttm
assert dttm_col.python_date_format == "epoch_s"
assert dttm_col.expression == "CAST(dttm as INTEGER)"
def test_sync_theme_logo_href() -> None:
"""
Verify LOGO_TARGET_PATH is wired into a theme's brandLogoHref.
THEME_DEFAULT is built before superset_config.py overrides load, so the link
is re-synced afterwards via sync_theme_logo_href. A provided LOGO_TARGET_PATH
must update brandLogoHref; None must leave the existing value untouched.
"""
from copy import deepcopy
from superset.config import sync_theme_logo_href, THEME_DEFAULT
# A user-provided LOGO_TARGET_PATH propagates to the logo link.
theme = deepcopy(THEME_DEFAULT)
theme["token"]["brandLogoHref"] = "/"
sync_theme_logo_href(theme, "https://custom.url")
assert theme["token"]["brandLogoHref"] == "https://custom.url"
# The default (None) leaves the existing link untouched.
default_theme = deepcopy(THEME_DEFAULT)
default_theme["token"]["brandLogoHref"] = "/"
sync_theme_logo_href(default_theme, None)
assert default_theme["token"]["brandLogoHref"] == "/"
# A disabled theme (None) is a no-op rather than an error.
sync_theme_logo_href(None, "https://custom.url")
def test_theme_default_logo_defaults() -> None:
"""With the shipped defaults, brandLogoHref is "/" and brandLogoUrl is APP_ICON."""
from superset import config
assert config.LOGO_TARGET_PATH is None
assert config.THEME_DEFAULT["token"]["brandLogoHref"] == "/"
assert config.THEME_DEFAULT["token"]["brandLogoUrl"] == config.APP_ICON

View File

@@ -0,0 +1,60 @@
# 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 uuid
from superset.commands.dashboard import export as export_module
class DummySlice:
def __init__(self, id_: int, slice_uuid: uuid.UUID, slice_name: str = "chart"):
self.id = id_
self.uuid = slice_uuid
self.slice_name = slice_name
def test_append_deterministic_fields():
# start with a default position (has ROOT_ID and GRID_ID)
position = export_module.get_default_position("test")
# create two dummy slices with known UUIDs
u1 = uuid.UUID("11111111-1111-1111-1111-111111111111")
u2 = uuid.UUID("22222222-2222-2222-2222-222222222222")
s1 = DummySlice(101, u1, "Chart One")
s2 = DummySlice(102, u2, "Chart Two")
charts = {s1, s2}
new_pos = export_module.append_charts(position, charts)
# chart keys should be CHART-<uuid>
expected_keys = {f"CHART-{str(u1)}", f"CHART-{str(u2)}"}
# row key should be ROW-N-<row number>
# expected row number is 0 since we started with only ROOT_ID and GRID_ID
expected_row = "ROW-N-0"
assert expected_row in new_pos, "expected row key in position"
for k in expected_keys:
assert k in new_pos, f"expected chart key {k} in position"
# Ensure meta.uuid equals the chart uuid and chartId equals id
for chart in (s1, s2):
key = f"CHART-{str(chart.uuid)}"
meta = new_pos[key]["meta"]
assert meta["uuid"] == str(chart.uuid)
assert meta["chartId"] == chart.id
assert meta["sliceName"] == chart.slice_name

View File

@@ -430,6 +430,30 @@ def test_get_default_catalog(mocker: MockerFixture) -> None:
assert BigQueryEngineSpec.get_default_catalog(database) == "project"
def test_get_time_partition_column_uses_catalog_in_table_reference(
mocker: MockerFixture,
) -> None:
"""
Test that partition metadata lookup preserves the BigQuery project.
"""
from superset.db_engine_specs.bigquery import BigQueryEngineSpec
database = mock.Mock()
engine = mock.MagicMock()
get_engine = mocker.patch.object(BigQueryEngineSpec, "get_engine")
get_engine.return_value.__enter__.return_value = engine
client = mocker.patch.object(BigQueryEngineSpec, "_get_client").return_value
client.get_table.return_value.time_partitioning.field = "ds"
result = BigQueryEngineSpec.get_time_partition_column(
database,
Table("my_table", "my_dataset", "other_project"),
)
assert result == "ds"
client.get_table.assert_called_once_with("other_project.my_dataset.my_table")
def test_adjust_engine_params_catalog_as_host() -> None:
"""
Test passing a custom catalog.

View File

@@ -73,6 +73,14 @@ def mock_auth():
yield mock_get_user
@pytest.fixture(autouse=True)
def mock_event_logger():
"""Skip event-logger DB writes so a bad logs FK doesn't poison the
session for FastMCP's response serialization on the success path."""
with patch("superset.utils.log.DBEventLogger.log", return_value=None):
yield
@pytest.fixture(autouse=True)
def mock_dataset_access_granted():
"""Grant dataset access by default; tests that need a denial override this."""
@@ -167,14 +175,16 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["permalink_key"] == "test_permalink_key"
assert result.data["form_data_key"] is None
assert result.data["chart_type_label"] == "table chart"
assert result.structured_content["permalink_key"] == "test_permalink_key"
assert result.structured_content["form_data_key"] is None
assert result.structured_content["chart_type_label"] == "table chart"
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
@@ -204,14 +214,16 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["permalink_key"] == "test_permalink_key"
assert result.data["form_data_key"] is None
assert result.data["chart_type_label"] == "table chart"
assert result.structured_content["permalink_key"] == "test_permalink_key"
assert result.structured_content["form_data_key"] is None
assert result.structured_content["chart_type_label"] == "table chart"
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
@@ -233,8 +245,13 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.data["chart_type_label"] == "interactive table chart"
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.structured_content["chart_type_label"]
== "interactive table chart"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
@@ -264,12 +281,14 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["chart_type_label"] is None
assert result.structured_content["chart_type_label"] is None
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
@@ -292,9 +311,11 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@@ -324,9 +345,11 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@@ -354,9 +377,11 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@@ -392,13 +417,15 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/?form_data_key=fallback_form_data_key"
)
assert result.data["form_data_key"] == "fallback_form_data_key"
assert result.data["permalink_key"] is None
assert (
result.structured_content["form_data_key"] == "fallback_form_data_key"
)
assert result.structured_content["permalink_key"] is None
mock_create_form_data.assert_called_once()
@patch(_PERMALINK_PATCH)
@@ -434,9 +461,10 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/?datasource_type=table&datasource_id=1"
)
@@ -473,9 +501,10 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/?form_data_key=lock_fallback_key"
)
@@ -505,9 +534,11 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@@ -543,9 +574,11 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@@ -595,10 +628,11 @@ class TestGenerateExploreLink:
# All URLs should follow the same permalink format
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
@@ -621,9 +655,10 @@ class TestGenerateExploreLink:
result = await client.call_tool(
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@@ -661,9 +696,11 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@@ -708,8 +745,9 @@ class TestGenerateExploreLink:
f"http://localhost:9001/explore/?datasource_type=table"
f"&datasource_id={dataset_id}"
)
assert result.data["error"] is None
assert result.data["url"] == expected_url
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert result.structured_content["url"] == expected_url
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
@@ -744,12 +782,19 @@ class TestGenerateExploreLink:
)
# Should return error response with empty URL
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Invalid config structure" in result.data["error"]
assert result.structured_content["url"] == ""
assert result.structured_content["form_data"] == {}
assert result.structured_content["form_data_key"] is None
assert result.structured_content["permalink_key"] is None
assert result.structured_content["chart_type_label"] is None
assert result.structured_content["success"] is False
error = result.structured_content["error"]
assert error["error_type"] == "generation_failed"
# ``details`` is the static, sanitized message; the raw
# exception text ("Invalid config structure") is kept
# only in the server-side log, not echoed to the client.
assert "check server logs" in error["details"]
assert "Invalid config structure" not in error["details"]
finally:
# Restore original function
explore_module.map_config_to_form_data = original_func
@@ -774,11 +819,14 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.data["permalink_key"] == "extracted_permalink_xyz"
assert result.data["form_data_key"] is None
assert "extracted_permalink_xyz" in result.data["url"]
assert result.data["url"] == (
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.structured_content["permalink_key"] == "extracted_permalink_xyz"
)
assert result.structured_content["form_data_key"] is None
assert "extracted_permalink_xyz" in result.structured_content["url"]
assert result.structured_content["url"] == (
"http://localhost:9001/explore/p/extracted_permalink_xyz/"
)
@@ -803,13 +851,20 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert "form_data" in result.data
assert isinstance(result.data["form_data"], dict)
assert result.data["form_data"].get("viz_type") == "echarts_timeseries_line"
assert result.data["form_data"].get("x_axis") == "date"
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert "form_data" in result.structured_content
assert isinstance(result.structured_content["form_data"], dict)
assert (
result.structured_content["form_data"].get("viz_type")
== "echarts_timeseries_line"
)
assert result.structured_content["form_data"].get("x_axis") == "date"
# Verify datasource field format: "{dataset_id}__table"
assert result.data["form_data"].get("datasource") == "1__table"
assert (
result.structured_content["form_data"].get("datasource") == "1__table"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
@@ -829,20 +884,26 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Dataset not found: 99999" in result.data["error"]
assert "list_datasets" in result.data["error"]
assert result.structured_content["url"] == ""
assert result.structured_content["form_data"] == {}
assert result.structured_content["form_data_key"] is None
assert result.structured_content["permalink_key"] is None
assert result.structured_content["chart_type_label"] is None
assert result.structured_content["success"] is False
error = result.structured_content["error"]
assert error["error_type"] == "dataset_not_found"
assert "Dataset not found: 99999" in error["message"]
assert "list_datasets" in error["details"]
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
async def test_generate_explore_link_without_config(
self, mock_find_dataset, mcp_server
):
"""Omitting config returns a default dataset explore URL."""
"""Omitting config returns a default dataset explore URL through
the same typed ``GenerateExploreLinkResponse`` shape as every
other code path. ``success=True`` and ``error=None`` so callers
cannot mistake a no-config response for a failure."""
mock_find_dataset.return_value = _mock_dataset(id=42)
request = GenerateExploreLinkRequest(dataset_id="42")
@@ -852,23 +913,26 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
assert (
result.data["url"]
result.structured_content["url"]
== "http://localhost:9001/explore/?datasource_type=table"
"&datasource_id=42"
)
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert result.structured_content["form_data"] == {}
assert result.structured_content["form_data_key"] is None
assert result.structured_content["permalink_key"] is None
assert result.structured_content["chart_type_label"] is None
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
async def test_generate_explore_link_without_config_missing_dataset(
self, mock_find_dataset, mcp_server
):
"""Omitting config still surfaces a dataset-not-found error."""
"""Omitting config still surfaces a dataset-not-found error
through the structured error object — not as a substring on a
dict, which is the bug this test originally hid."""
mock_find_dataset.return_value = None
request = GenerateExploreLinkRequest(dataset_id="99999")
@@ -878,12 +942,15 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Dataset not found: 99999" in result.data["error"]
assert result.structured_content["url"] == ""
assert result.structured_content["form_data"] == {}
assert result.structured_content["form_data_key"] is None
assert result.structured_content["permalink_key"] is None
assert result.structured_content["chart_type_label"] is None
assert result.structured_content["success"] is False
error = result.structured_content["error"]
assert error["error_type"] == "dataset_not_found"
assert "Dataset not found: 99999" in error["message"]
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@pytest.mark.asyncio
@@ -905,12 +972,15 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Dataset not found" in result.data["error"]
assert result.structured_content["url"] == ""
assert result.structured_content["form_data"] == {}
assert result.structured_content["form_data_key"] is None
assert result.structured_content["permalink_key"] is None
assert result.structured_content["chart_type_label"] is None
assert result.structured_content["success"] is False
error = result.structured_content["error"]
assert error["error_type"] == "dataset_not_found"
assert "Dataset not found" in error["message"]
class TestGenerateExploreLinkColumnNormalization:
@@ -959,9 +1029,11 @@ class TestGenerateExploreLinkColumnNormalization:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
# x-axis should be normalized from 'orderdate' to 'OrderDate'
assert result.data["form_data"]["x_axis"] == "OrderDate"
assert result.structured_content["form_data"]["x_axis"] == "OrderDate"
@patch(
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator._get_dataset_context"
@@ -1004,8 +1076,10 @@ class TestGenerateExploreLinkColumnNormalization:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
form_data = result.data["form_data"]
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
form_data = result.structured_content["form_data"]
# x-axis normalized
assert form_data["x_axis"] == "OrderDate"
# filter subject normalized to match x-axis
@@ -1045,9 +1119,11 @@ class TestGenerateExploreLinkColumnNormalization:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert result.structured_content["error"] is None
assert result.structured_content["success"] is True
# original names should pass through unchanged
assert result.data["form_data"]["x_axis"] == "orderdate"
assert result.structured_content["form_data"]["x_axis"] == "orderdate"
class TestGenerateExploreLinkValidation:
@@ -1105,11 +1181,12 @@ class TestGenerateExploreLinkValidation:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["url"] == ""
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
error = result.data["error"]
assert result.structured_content["url"] == ""
assert result.structured_content["form_data_key"] is None
assert result.structured_content["permalink_key"] is None
assert result.structured_content["chart_type_label"] is None
assert result.structured_content["success"] is False
error = result.structured_content["error"]
assert isinstance(error, dict)
assert error["error_code"] == "CHART_VALIDATION_FAILED"
assert "sum_boys" in error["suggestions"]
@@ -1141,8 +1218,12 @@ class TestGenerateExploreLinkValidation:
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["url"] == ""
assert result.data["chart_type_label"] is None
# Surface as "not found" rather than leaking that the dataset exists.
assert "Dataset not found" in result.data["error"]
assert result.structured_content["url"] == ""
assert result.structured_content["chart_type_label"] is None
assert result.structured_content["success"] is False
error = result.structured_content["error"]
# error_type lets programmatic callers distinguish, while the
# user-facing message still avoids leaking dataset existence.
assert error["error_type"] == "permission_denied"
assert "Dataset not found" in error["message"]
mock_create_permalink.assert_not_called()