Compare commits

...

23 Commits

Author SHA1 Message Date
Evan
0e2495696a test(config): expect SMTP_TIMEOUT kwarg in SSL call assertions
PR #41250 added timeout=smtp_timeout to the smtplib.SMTP_SSL calls in
send_mime_email; update the unit-test assertions to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:55:34 -07:00
Evan
3801441db5 test(email): add return type annotations to setUp/tearDown
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:55:34 -07:00
Evan
9eab97fe09 test(email): restore mutated SMTP config keys in TestEmailSmtp teardown
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:55:34 -07:00
Evan
4008f2b1b1 docs(tests): add docstring to _smtp_config test helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:55:34 -07:00
Evan
ca15ae4bcb chore(config): correct SMTP_SSL_SERVER_AUTH comment to SERVER_AUTH
ssl.create_default_context() defaults to ssl.Purpose.SERVER_AUTH, which is
correct for Superset acting as an SMTP client verifying the mail server's
certificate. The comment incorrectly referenced CLIENT_AUTH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:55:34 -07:00
Evan
806e41c09e test(config): cover SMTP_SSL_SERVER_AUTH enabled behavior
Add unit tests in config_test.py that exercise send_mime_email and assert
ssl.create_default_context() is called and its context is threaded through
to SMTP_SSL and starttls when SMTP_SSL_SERVER_AUTH=True, plus the opt-out
path passing context=None. Complements the existing module-level default
assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:55:34 -07:00
Evan
85290092f8 test(email): explicitly opt out of SMTP_SSL_SERVER_AUTH in test_send_mime_ssl
The new default for SMTP_SSL_SERVER_AUTH is True. test_send_mime_ssl
tests the no-server-auth code path and must explicitly set the flag to
False to avoid asserting context=None when the default now produces an
SSL context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 05:55:34 -07:00
Claude Code
0cf48d4429 chore(config): default SMTP_SSL_SERVER_AUTH to True
Change the shipped default for SMTP_SSL_SERVER_AUTH from False to True so
STARTTLS/SSL connections to the SMTP server validate the server's TLS
certificate against the system CA store out of the box.

The setting remains overridable: operators using a self-signed or otherwise
untrusted certificate can restore the previous behavior by setting
SMTP_SSL_SERVER_AUTH = False in superset_config.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:55:33 -07:00
Evan Rusackas
3261d10270 chore(frontend): enforce TypeScript-only source files (#41385)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 05:54:37 -07:00
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 2158 additions and 543 deletions

View File

@@ -149,6 +149,18 @@ Runbook to adopt:
2. Set that value on the tunnel's `server_host_key` (via the database/SSH tunnel API or UI payload).
3. Optionally set `SSH_TUNNEL_STRICT_HOST_KEY_CHECKING = True` in `superset_config.py` to require host-key verification on all tunnels.
### SMTP server certificate validation enabled by default
`SMTP_SSL_SERVER_AUTH` now defaults to `True` (previously `False`). With this default, STARTTLS/SSL connections to the configured SMTP server validate the server's TLS certificate against the system trusted CA store. This makes outbound email (alerts and reports) verify the mail server's identity out of the box.
If your SMTP server presents a self-signed certificate, or a certificate that is not trusted by the system CA store, email delivery may now fail with a certificate verification error. To restore the previous behavior of skipping certificate validation, set the following in `superset_config.py`:
```python
SMTP_SSL_SERVER_AUTH = False
```
The recommended fix is to add the SMTP server's certificate (or its issuing CA) to the system trust store rather than disabling validation.
### Dataset import validates catalog against the target connection
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.

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

@@ -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

@@ -632,6 +632,35 @@ function processFile(filepath) {
}
}
/**
* Application source trees that must be authored in TypeScript. Matches the
* top-level `src/` directory as well as each package/plugin `src/` directory.
*/
const TS_ONLY_SOURCE_PATTERN =
/^(src|packages\/[^/]+\/src|plugins\/[^/]+\/src)\//;
/**
* Enforce the TypeScript-only frontend convention: no `.js`/`.jsx` files may be
* added under the application source trees (including test files). Build
* artifacts and root-level config files (e.g. `.storybook/preview.jsx`,
* `webpack.config.js`) live outside these trees and are intentionally allowed.
*
* @param {string[]} candidateFiles paths relative to `superset-frontend/`
*/
function checkTypeScriptOnlySource(candidateFiles) {
candidateFiles.forEach(file => {
if (TS_ONLY_SOURCE_PATTERN.test(file) && /\.(js|jsx)$/.test(file)) {
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${file}: frontend source must be TypeScript. ` +
`Rename to .ts/.tsx (the codebase is mid-migration to full ` +
`TypeScript; no new .js/.jsx files in src/).`,
);
errorCount += 1;
}
});
}
/**
* Main function
*/
@@ -666,6 +695,22 @@ function main() {
/packages\/superset-ui-core\/src\/color\/index\.ts/, // Core brand color constants
];
// Enforce TypeScript-only source. Run this on the raw file list (before the
// ignore patterns below strip out tests/stories) so that e.g. a new
// `*.test.jsx` is still rejected.
const tsOnlyCandidates =
args.length === 0
? glob.sync('{src,packages/*/src,plugins/*/src}/**/*.{js,jsx}', {
ignore: [
'**/node_modules/**',
'**/esm/**',
'**/lib/**',
'**/dist/**',
],
})
: args.map(f => f.replace(/^superset-frontend\//, ''));
checkTypeScriptOnlySource(tsOnlyCandidates);
// If no files specified, check all
if (files.length === 0) {
files = glob.sync('src/**/*.{ts,tsx,js,jsx}', {
@@ -706,22 +751,23 @@ function main() {
if (files.length === 0) {
// eslint-disable-next-line no-console
console.log('No files to check.');
return;
} else {
// eslint-disable-next-line no-console
console.log(
`Checking ${files.length} files for Superset custom rules...\n`,
);
files.forEach(file => {
// Resolve the file path
const resolvedPath = path.resolve(file);
if (fs.existsSync(resolvedPath)) {
processFile(resolvedPath);
} else if (fs.existsSync(file)) {
processFile(file);
}
});
}
// eslint-disable-next-line no-console
console.log(`Checking ${files.length} files for Superset custom rules...\n`);
files.forEach(file => {
// Resolve the file path
const resolvedPath = path.resolve(file);
if (fs.existsSync(resolvedPath)) {
processFile(resolvedPath);
} else if (fs.existsSync(file)) {
processFile(file);
}
});
// eslint-disable-next-line no-console
console.log(`\n${errorCount} errors, ${warningCount} warnings`);
@@ -740,4 +786,5 @@ module.exports = {
checkNoFaIcons,
checkI18nTemplates,
checkUntranslatedStrings,
checkTypeScriptOnlySource,
};

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:
@@ -1739,9 +1766,14 @@ SMTP_USER = "superset"
SMTP_PORT = 25
SMTP_PASSWORD = "superset" # noqa: S105
SMTP_MAIL_FROM = "superset@superset.com"
# If True creates a default SSL context with ssl.Purpose.CLIENT_AUTH using the
# default system root CA certificates.
SMTP_SSL_SERVER_AUTH = False
# If True creates a default SSL context with ssl.Purpose.SERVER_AUTH using the
# default system root CA certificates. This makes STARTTLS/SSL connections to the
# SMTP server validate the server's certificate against the trusted CA store.
# Defaults to True so the mail server identity is verified out of the box. Set to
# False to restore the previous behavior of skipping certificate validation (for
# example, when using a self-signed certificate that is not in the system CA
# store).
SMTP_SSL_SERVER_AUTH = True
# Socket timeout (in seconds) for the SMTP connection used when sending
# alert/report emails. Without a timeout the underlying socket blocks
# indefinitely if the SMTP server becomes unreachable, which leaves report
@@ -2831,3 +2863,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

@@ -37,9 +37,18 @@ logger = logging.getLogger(__name__)
class TestEmailSmtp(SupersetTestCase):
def setUp(self):
SMTP_CONFIG_KEYS = ("SMTP_SSL", "SMTP_SSL_SERVER_AUTH", "SMTP_STARTTLS")
def setUp(self) -> None:
self._original_smtp_config = {
key: current_app.config[key] for key in self.SMTP_CONFIG_KEYS
}
current_app.config["SMTP_SSL"] = False
def tearDown(self) -> None:
current_app.config.update(self._original_smtp_config)
super().tearDown()
@mock.patch("superset.utils.core.send_mime_email")
def test_send_smtp(self, mock_send_mime):
attachment = tempfile.NamedTemporaryFile()
@@ -210,6 +219,7 @@ class TestEmailSmtp(SupersetTestCase):
@mock.patch("smtplib.SMTP")
def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
current_app.config["SMTP_SSL"] = True
current_app.config["SMTP_SSL_SERVER_AUTH"] = False
mock_smtp.return_value = mock.Mock()
mock_smtp_ssl.return_value = mock.Mock()
utils.send_mime_email(

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,160 @@ 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
def test_smtp_ssl_server_auth_defaults_to_true() -> None:
"""
The shipped default for SMTP_SSL_SERVER_AUTH validates the SMTP server's
TLS certificate. Operators can still opt out by overriding it to False.
"""
from superset import config
assert config.SMTP_SSL_SERVER_AUTH is True
def _smtp_config(**overrides: Any) -> dict[str, Any]:
"""
Build a minimal SMTP config dict for ``send_mime_email`` tests, with
plaintext transport defaults; keyword ``overrides`` replace any key.
"""
config = {
"SMTP_HOST": "localhost",
"SMTP_PORT": 25,
"SMTP_USER": "",
"SMTP_PASSWORD": "",
"SMTP_STARTTLS": False,
"SMTP_SSL": False,
"SMTP_SSL_SERVER_AUTH": True,
}
config.update(overrides)
return config
def test_send_mime_email_ssl_server_auth_passes_context(
mocker: MockerFixture,
) -> None:
"""
With SMTP_SSL and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
default SSL context and threads it through to ``smtplib.SMTP_SSL`` so the
server certificate is validated.
"""
from email.mime.multipart import MIMEMultipart
from superset.utils import core as utils
create_default_context = mocker.patch(
"superset.utils.core.ssl.create_default_context"
)
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
smtp = mocker.patch("smtplib.SMTP")
utils.send_mime_email(
"from",
["to"],
MIMEMultipart(),
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=True),
dryrun=False,
)
create_default_context.assert_called_once_with()
assert not smtp.called
smtp_ssl.assert_called_once_with(
"localhost", 25, context=create_default_context.return_value, timeout=30
)
def test_send_mime_email_starttls_server_auth_passes_context(
mocker: MockerFixture,
) -> None:
"""
With STARTTLS and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
default SSL context and threads it through to ``starttls`` so the server
certificate is validated.
"""
from email.mime.multipart import MIMEMultipart
from superset.utils import core as utils
create_default_context = mocker.patch(
"superset.utils.core.ssl.create_default_context"
)
smtp = mocker.patch("smtplib.SMTP")
utils.send_mime_email(
"from",
["to"],
MIMEMultipart(),
_smtp_config(SMTP_STARTTLS=True, SMTP_SSL_SERVER_AUTH=True),
dryrun=False,
)
create_default_context.assert_called_once_with()
smtp.return_value.starttls.assert_called_once_with(
context=create_default_context.return_value
)
def test_send_mime_email_server_auth_disabled_skips_context(
mocker: MockerFixture,
) -> None:
"""
When SMTP_SSL_SERVER_AUTH is disabled no SSL context is built and ``None`` is
passed through, preserving the opt-out (certificate validation skipped).
"""
from email.mime.multipart import MIMEMultipart
from superset.utils import core as utils
create_default_context = mocker.patch(
"superset.utils.core.ssl.create_default_context"
)
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
utils.send_mime_email(
"from",
["to"],
MIMEMultipart(),
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=False),
dryrun=False,
)
assert not create_default_context.called
smtp_ssl.assert_called_once_with("localhost", 25, context=None, timeout=30)

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()