mirror of
https://github.com/apache/superset.git
synced 2026-06-26 18:09:21 +00:00
Compare commits
23 Commits
chore/ci-f
...
fix/smtp-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e2495696a | ||
|
|
3801441db5 | ||
|
|
9eab97fe09 | ||
|
|
4008f2b1b1 | ||
|
|
ca15ae4bcb | ||
|
|
806e41c09e | ||
|
|
85290092f8 | ||
|
|
0cf48d4429 | ||
|
|
3261d10270 | ||
|
|
a57b5f6078 | ||
|
|
d1b523b97f | ||
|
|
91188a0302 | ||
|
|
ac234d0fb2 | ||
|
|
8eb753eab2 | ||
|
|
779fa13679 | ||
|
|
caf81e71d2 | ||
|
|
1b8c6d109d | ||
|
|
eb60e5477b | ||
|
|
7b9bcdd951 | ||
|
|
d9d395bde1 | ||
|
|
584d41759b | ||
|
|
8f22b71898 | ||
|
|
1ea3584dcb |
12
UPDATING.md
12
UPDATING.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,21 +22,50 @@ under the License.
|
||||
[](https://www.npmjs.com/package/@superset-ui/core)
|
||||
[](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
|
||||
```
|
||||
|
||||
@@ -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' }];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
{},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
66
superset-frontend/src/features/roles/RoleFormItems.test.tsx
Normal file
66
superset-frontend/src/features/roles/RoleFormItems.test.tsx
Normal 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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }] }
|
||||
: {}),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
92
superset/mcp_service/explore/schemas.py
Normal file
92
superset/mcp_service/explore/schemas.py
Normal 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.",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
60
tests/unit_tests/dashboards/export_append_charts_tests.py
Normal file
60
tests/unit_tests/dashboards/export_append_charts_tests.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user