Compare commits

...
Author SHA1 Message Date
Enzo Martellucci 1bac9807aa Merge branch 'master' into enxdev/fix/explore-chart-data 2026-08-28 18:49:26 +02:00
Ville Brofeldt 4ced5ca35a chore(superset-core): drop unused __all__ lists (#43626) 2026-08-28 09:46:56 -07:00
rlei fc26991cd4 feat(plugin-chart-echarts): add gridline and axis tick controls (#43428) 2026-08-28 09:44:39 -07:00
Enzo Martellucci 5f79743799 Merge branch 'master' into enxdev/fix/explore-chart-data 2026-08-28 18:44:22 +02:00
shauryaandShaurya a5c68c8df9 fix(number-format): handle sub-byte values and unit rollover in memory formatter (#43549)
Co-authored-by: Shaurya <19599684+no-hup@users.noreply.github.com>
2026-08-28 09:44:19 -07:00
Đỗ Trọng Hải 53e76afd70 feat(ci): enforce min release age for npm dep installation (#43164)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-08-28 23:42:41 +07:00
Enzo MartellucciandClaude Sonnet 5 2516bf0166 fix(explore): drop redundant .ant-tabs height rules from ResultsPaneOnDashboard
Tabs already emits height: 100% on .ant-tabs-body and .ant-tabs-content
when fullHeight is set, so the Wrapper's own .ant-tabs/.ant-tabs-body
overrides were dead weight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 18:40:54 +02:00
Enzo Martellucci 3f61dd8bdc Merge branch 'master' into enxdev/fix/explore-chart-data 2026-08-28 18:39:22 +02:00
shauryaandShaurya d997d363e3 docs: update frontend Node/npm prerequisites to match engines (#43546)
Co-authored-by: Shaurya <19599684+no-hup@users.noreply.github.com>
2026-08-28 23:16:34 +07:00
Lalith Kothuru 9a6f6ee0c0 docs: fix docstring parameter names that do not match signatures (#43630) 2026-08-28 22:57:43 +07:00
Joe Li 94dd3d049c fix(ci): repair scheduled pre-commit drift (#43603) 2026-08-28 22:56:18 +07:00
b3f718da62 fix(explore): keep certification badges after saving or swapping a dataset (#43319)
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 08:47:36 -07:00
Mehmet Salih YavuzandEnzo Martellucci abf338d611 feat(filters): search filter values server-side in Explore (#43518)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-08-28 16:46:09 +03:00
Enzo Martellucci 36f6c22660 Merge branch 'master' into enxdev/fix/explore-chart-data 2026-08-28 15:44:38 +02:00
Enzo Martellucci c9eebc0744 test(explore): cover fullHeight prop passed to ResultsPaneOnDashboard's Tabs
Splitting the stale-tab work into its own PR (#43648) took the only
test asserting fullHeight with it. Add coverage back here, reading
only Tabs' first call argument so the assertion doesn't depend on
React 18's legacy-context second argument, which React 19 drops.
2026-08-28 15:39:45 +02:00
Enzo Martellucci 304b9c10e0 Revert "fix(dashboard): reconcile stale results tab in Chart Data modal"
This reverts commit 50f4802bbf.
2026-08-28 15:00:52 +02:00
Enzo Martellucci b0528f0bf2 Revert "refactor(explore): dedupe stale results-tab fallback into a shared hook"
This reverts commit 7870fda6ab.
2026-08-28 15:00:52 +02:00
Enzo Martellucci b913ee27a0 Revert "test(explore): reset useResultsPane mock between ResultsPaneOnDashboard tests"
This reverts commit 13dd39abb1.
2026-08-28 15:00:52 +02:00
Enzo Martellucci 7de38c2af1 Revert "test(explore): stop asserting Tabs' legacy-context second argument"
This reverts commit 9ebbbc87f3.
2026-08-28 15:00:52 +02:00
Mehmet Salih Yavuz e518b21994 fix(dashboard): keep the chart menu usable in fullscreen on production builds (#43555) 2026-08-28 15:47:48 +03:00
Mehmet Salih Yavuz e18f27e1ce fix(native-filters): allow clearing an optional "select first value" filter (#43405) 2026-08-28 15:46:46 +03:00
Enzo MartellucciandClaude Sonnet 5 9ebbbc87f3 test(explore): stop asserting Tabs' legacy-context second argument
toHaveBeenCalledWith(matcher, expect.anything()) only passed because React
18 still calls function components with a legacy-context {} second
argument; React 19 drops it, which would break this assertion's arg-count
check for reasons unrelated to Tabs' actual props. Inspect the first call
argument directly instead, which is agnostic to whether a second one
exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:16:54 +02:00
Enzo MartellucciandClaude Sonnet 5 13dd39abb1 test(explore): reset useResultsPane mock between ResultsPaneOnDashboard tests
The last test's mockReturnValue overrides for useResultsPane persisted
after the test finished, since nothing restored the mock's implementation.
Restore it in afterEach so later tests keep getting the real hook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:13:30 +02:00
Enzo MartellucciandClaude Sonnet 5 7870fda6ab refactor(explore): dedupe stale results-tab fallback into a shared hook
DataTablesPane and ResultsPaneOnDashboard each reconciled activeTabKey
against the current tab keys with an identical getStaleResultsTabFallback
+ useEffect pair. Extract useStaleResultsTabFallback so both call sites
share one implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:13:01 +02:00
Enzo Martellucci 4011845d92 Merge branch 'master' into enxdev/fix/explore-chart-data 2026-08-28 11:44:47 +02:00
Enzo MartellucciandClaude Sonnet 5 50f4802bbf fix(dashboard): reconcile stale results tab in Chart Data modal
Address PR #43454 review feedback: reuse DataTablesPane's stale-tab
fallback so ResultsPaneOnDashboard doesn't render blank when a mixed
chart's active results tab disappears, and drop CSS rules now
redundant with the fullHeight prop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:16:21 +02:00
Enzo Martellucci fd07663dc1 fix(dashboard): stretch Chart Data modal results grid to fill available height 2026-08-24 13:32:15 +02:00
72 changed files with 1315 additions and 292 deletions
+1
View File
@@ -10,6 +10,7 @@
.stylelintignore
.flake8
.nvmrc
.npmrc
.rat-excludes
.swcrc
.*log
@@ -493,8 +493,8 @@ Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in or
First, be sure you are using the following versions of Node.js and npm:
- `Node.js`: Version 22 (LTS)
- `npm`: Version 10
- `Node.js`: Version 24 (see `superset-frontend/.nvmrc` for the exact version)
- `npm`: Version 11
We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage your node environment:
@@ -507,8 +507,8 @@ export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
cd superset-frontend
nvm install --lts
nvm use --lts
nvm install
nvm use
```
Or if you use the default macOS starting with Catalina shell `zsh`, try:
@@ -390,19 +390,3 @@ def get_session() -> scoped_session:
:returns: The SQLAlchemy scoped session instance.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"Dataset",
"Database",
"Chart",
"Dashboard",
"User",
"Role",
"Group",
"Tag",
"KeyValue",
"Subject",
"CoreModel",
"get_session",
]
@@ -183,10 +183,3 @@ def prompt(
"MCP prompt decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = [
"tool",
"prompt",
"ToolAnnotations",
]
@@ -55,9 +55,3 @@ class SavedQueryDAO(BaseDAO[SavedQuery]):
model_cls = None
base_filter = None
id_column_name = "id"
__all__ = [
"QueryDAO",
"SavedQueryDAO",
]
@@ -71,9 +71,3 @@ class SavedQuery(CoreModel):
database_id: int | None
description: str | None
user_id: int | None
__all__ = [
"Query",
"SavedQuery",
]
@@ -46,6 +46,3 @@ def get_sqlglot_dialect(database: "Database") -> Dialects:
:returns: The SQLGlot dialect enum corresponding to the database.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = ["get_sqlglot_dialect"]
@@ -165,13 +165,3 @@ class AsyncQueryHandle:
:returns: True if cancellation was successful
"""
raise NotImplementedError("Method will be replaced during initialization")
__all__ = [
"QueryStatus",
"QueryOptions",
"QueryResult",
"StatementResult",
"AsyncQueryHandle",
"CacheOptions",
]
@@ -27,6 +27,3 @@ class RestApi(BaseApi):
"""
allow_browser_login = True
__all__ = ["RestApi"]
@@ -98,6 +98,3 @@ def api(
"API decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["api"]
@@ -164,6 +164,3 @@ class AbstractSemanticViewDAO(BaseDAO[SemanticViewModel]):
:return: SemanticViewModel instance or None
"""
...
__all__ = ["AbstractSemanticLayerDAO", "AbstractSemanticViewDAO"]
@@ -97,6 +97,3 @@ def semantic_layer(
"Semantic layer decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["semantic_layer"]
@@ -21,6 +21,7 @@ from abc import ABC, abstractmethod
from typing import Any, Generic, TypeVar
from pydantic import BaseModel
from superset_core.semantic_layers.view import SemanticView
ConfigT = TypeVar("ConfigT", bound=BaseModel)
@@ -80,6 +80,3 @@ class SemanticViewModel(CoreModel):
semantic_layer_uuid: UUID
created_on: datetime | None
changed_on: datetime | None
__all__ = ["SemanticLayerModel", "SemanticViewModel"]
@@ -71,6 +71,3 @@ class TaskDAO(BaseDAO[Task]):
:returns: Task instance or None if not found or not active
"""
...
__all__ = ["TaskDAO"]
@@ -144,9 +144,3 @@ def get_context() -> TaskContext:
)
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"task",
"get_context",
]
@@ -161,9 +161,3 @@ class TaskSubscriber(CoreModel):
changed_on: datetime | None
created_by_fk: int | None
changed_by_fk: int | None
__all__ = [
"Task",
"TaskSubscriber",
]
@@ -226,12 +226,3 @@ class TaskContext(ABC):
cleanup_partial_work()
"""
...
__all__ = [
"TaskStatus",
"TaskScope",
"TaskProperties",
"TaskContext",
"TaskOptions",
]
+1
View File
@@ -0,0 +1 @@
../superset-frontend/.npmrc
+1
View File
@@ -0,0 +1 @@
min-release-age=3
+1 -1
View File
@@ -392,7 +392,7 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"core-js": "^3.38.1",
"dompurify": "^3.4.11",
"dompurify": "^3.4.13",
"esbuild": "^0.28.1",
"eslint-plugin-import": {
"eslint": "$eslint"
@@ -38,11 +38,21 @@ function formatMemory(
: ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'RB', 'QB'];
const base = binary ? 1024 : 1000;
const i = Math.min(
suffixes.length - 1,
Math.floor(Math.log(absValue) / Math.log(base)),
let i = Math.max(
0,
Math.min(
suffixes.length - 1,
Math.floor(Math.log(absValue) / Math.log(base)),
),
);
formatted = `${sign}${parseFloat((absValue / Math.pow(base, i)).toFixed(decimals))}${suffixes[i]}`;
let scaled = parseFloat((absValue / Math.pow(base, i)).toFixed(decimals));
if (scaled >= base && i < suffixes.length - 1) {
i += 1;
scaled = parseFloat((absValue / Math.pow(base, i)).toFixed(decimals));
}
formatted = `${sign}${scaled}${suffixes[i]}`;
}
if (transfer) {
@@ -60,6 +60,31 @@ test('formats float bytes in human readable format with default options', () =>
expect(formatter(1200.666)).toBe('1.2kB');
});
test('formats values below one byte without dropping the unit', () => {
const formatter = createMemoryFormatter();
expect(formatter(0.5)).toBe('0.5B');
expect(formatter(0.004)).toBe('0B');
expect(formatter(-0.25)).toBe('-0.25B');
const binaryFormatter = createMemoryFormatter({ binary: true });
expect(binaryFormatter(0.5)).toBe('0.5B');
});
test('rolls over to the next unit when rounding reaches the base', () => {
const formatter = createMemoryFormatter();
expect(formatter(999999)).toBe('1MB');
expect(formatter(999995)).toBe('1MB');
expect(formatter(999994)).toBe('999.99kB');
expect(formatter(-999999)).toBe('-1MB');
const binaryFormatter = createMemoryFormatter({ binary: true });
expect(binaryFormatter(1024 * 1024 - 1)).toBe('1MiB');
// the largest unit has nothing to roll over into
const largest = createMemoryFormatter();
expect(largest(Math.pow(1000, 11))).toBe('1000QB');
});
test('formats bytes in human readable format with additional binary option', () => {
const formatter = createMemoryFormatter({ binary: true });
expect(formatter(0)).toBe('0B');
@@ -38,6 +38,8 @@ import { EchartsTimeseriesSeriesType } from '../Timeseries/types';
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
truncateXAxis,
xAxisBounds,
@@ -391,6 +393,8 @@ const config: ControlPanelConfig = {
...createCustomizeSection(t('Query B'), 'B'),
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
['x_axis_time_format'],
@@ -184,6 +184,8 @@ export default function transformProps(
opacityB,
minorSplitLine,
minorTicks,
gridlines,
axisTicks,
seriesType,
seriesTypeB,
showLegend,
@@ -788,6 +790,8 @@ export default function transformProps(
}),
},
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
...(gridlines ? {} : { splitLine: { show: false } }),
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
? (TIMEGRAIN_TO_TIMESTAMP[
@@ -818,6 +822,8 @@ export default function transformProps(
min: yAxisMin,
max: yAxisMax,
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
splitLine: { show: gridlines },
minorSplitLine: { show: minorSplitLine },
axisLabel: {
formatter: getYAxisFormatter(
@@ -840,6 +846,7 @@ export default function transformProps(
min: minSecondary,
max: maxSecondary,
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
splitLine: { show: false },
minorSplitLine: { show: minorSplitLine },
axisLabel: {
@@ -48,6 +48,8 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
// shared properties
minorSplitLine: boolean;
minorTicks: boolean;
gridlines: boolean;
axisTicks: boolean;
logAxis: boolean;
logAxisSecondary: boolean;
yAxisFormat?: string;
@@ -113,6 +115,8 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
...DEFAULT_LEGEND_FORM_DATA,
annotationLayers: [],
minorSplitLine: TIMESERIES_DEFAULTS.minorSplitLine,
gridlines: TIMESERIES_DEFAULTS.gridlines,
axisTicks: TIMESERIES_DEFAULTS.axisTicks,
truncateYAxis: TIMESERIES_DEFAULTS.truncateYAxis,
truncateYAxisSecondary: TIMESERIES_DEFAULTS.truncateYAxis,
logAxis: TIMESERIES_DEFAULTS.logAxis,
@@ -44,6 +44,8 @@ import {
truncateXAxis,
xAxisBounds,
minorTicks,
axisTicks,
gridlines,
forceMaxInterval,
} from '../../controls';
import { AreaChartStackControlOptions } from '../../constants';
@@ -174,6 +176,8 @@ const config: ControlPanelConfig = {
},
],
[minorTicks],
[axisTicks],
[gridlines],
['zoomable'],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
@@ -133,6 +133,8 @@ const defaultFormData: EchartsTimeseriesFormData & {
metrics: [],
minorSplitLine: false,
minorTicks: false,
gridlines: true,
axisTicks: true,
opacity: 1,
orderDesc: false,
rowLimit: 0,
@@ -40,6 +40,8 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSectionWithoutStream,
@@ -388,6 +390,8 @@ const config: ControlPanelConfig = {
},
],
[minorTicks],
[axisTicks],
[gridlines],
['zoomable'],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
@@ -37,6 +37,8 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSection,
@@ -156,6 +158,8 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
@@ -42,6 +42,8 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSection,
@@ -480,6 +482,8 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
...createAxisControl('x'),
@@ -37,6 +37,8 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSectionWithoutStack,
@@ -105,6 +107,8 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
@@ -35,6 +35,8 @@ import { DEFAULT_FORM_DATA, TIME_SERIES_DESCRIPTION_TEXT } from '../constants';
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSection,
@@ -157,6 +159,8 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
@@ -67,6 +67,8 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
maxMarkerSize: 30,
minMarkerSize: 5,
minorSplitLine: false,
gridlines: true,
axisTicks: true,
opacity: 0.2,
orderDesc: true,
rowLimit: 10000,
@@ -283,6 +283,8 @@ export default function transformProps(
metrics,
minorSplitLine,
minorTicks,
gridlines,
axisTicks,
onlyTotal,
opacity,
orientation,
@@ -1280,6 +1282,8 @@ export default function transformProps(
}),
},
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
...(gridlines ? {} : { splitLine: { show: false } }),
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
? (TIMEGRAIN_TO_TIMESTAMP[
@@ -1324,7 +1328,7 @@ export default function transformProps(
max: yAxisMax,
minorTick: { show: isSmallChart ? false : minorTicks },
minorSplitLine: { show: isSmallChart ? false : minorSplitLine },
splitLine: { show: !isSmallChart },
splitLine: { show: isSmallChart ? false : gridlines },
axisLabel: {
show: !isMicroChart,
showMinLabel: !isMicroChart,
@@ -1338,7 +1342,7 @@ export default function transformProps(
yAxisFormat,
),
},
axisTick: { show: !isSmallChart },
axisTick: { show: isSmallChart ? false : axisTicks },
scale: truncateYAxis,
name: isSmallChart ? undefined : yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
@@ -73,6 +73,8 @@ export type EchartsTimeseriesFormData = QueryFormData & {
metrics: QueryFormMetric[];
minorSplitLine: boolean;
minorTicks: boolean;
gridlines: boolean;
axisTicks: boolean;
opacity: number;
orderDesc: boolean;
rowLimit: number;
@@ -495,6 +495,28 @@ export const minorTicks: ControlSetItem = {
},
};
export const axisTicks: ControlSetItem = {
name: 'axisTicks',
config: {
type: 'CheckboxControl',
label: t('Axis ticks'),
default: true,
renderTrigger: true,
description: t('Show the main ticks on axes.'),
},
};
export const gridlines: ControlSetItem = {
name: 'gridlines',
config: {
type: 'CheckboxControl',
label: t('Gridlines'),
default: true,
renderTrigger: true,
description: t('Draw split lines for the main value axis ticks.'),
},
};
export const forceCategorical: ControlSetItem = {
name: 'forceCategorical',
config: {
@@ -116,6 +116,8 @@ const formData: EchartsMixedTimeseriesFormData = {
markerSizeB: 0,
minorSplitLine: false,
minorTicks: false,
gridlines: true,
axisTicks: true,
opacity: 0,
opacityB: 0,
orderDesc: false,
@@ -1509,3 +1511,56 @@ describe('EchartsMixedTimeseries tooltip truncation', () => {
expect(html).not.toContain(longSeriesName);
});
});
function transformWithChrome(
overrides: Partial<EchartsMixedTimeseriesFormData>,
) {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: { ...formData, ...overrides },
queriesData,
});
const { echartOptions } = transformProps(chartProps);
return {
xAxis: echartOptions.xAxis as any,
yAxis: echartOptions.yAxis as any[],
};
}
test('draws gridlines and axis ticks when both are enabled', () => {
const { xAxis, yAxis } = transformWithChrome({});
expect(yAxis[0].splitLine.show).toBe(true);
// Both axes keep ECharts' own default, which the Mixed chart never overrode.
expect(yAxis[0].axisTick.show).toBe('auto');
expect(xAxis.axisTick.show).toBe('auto');
});
test('hides the gridlines on the primary axis', () => {
const { xAxis, yAxis } = transformWithChrome({ gridlines: false });
expect(yAxis[0].splitLine.show).toBe(false);
// The secondary axis never draws gridlines, so the two grids cannot double up.
expect(yAxis[1].splitLine.show).toBe(false);
expect(xAxis.splitLine.show).toBe(false);
});
test('never turns the secondary axis gridlines on', () => {
const { xAxis, yAxis } = transformWithChrome({ gridlines: true });
expect(yAxis[0].splitLine.show).toBe(true);
expect(yAxis[1].splitLine.show).toBe(false);
expect(xAxis.splitLine).toBeUndefined();
});
test('hides the ticks on the x axis and both y axes', () => {
const { xAxis, yAxis } = transformWithChrome({ axisTicks: false });
expect(xAxis.axisTick.show).toBe(false);
expect(yAxis[0].axisTick.show).toBe(false);
expect(yAxis[1].axisTick.show).toBe(false);
});
@@ -2766,3 +2766,76 @@ describe('tooltip for metrics whose labels end in forecast suffixes', () => {
expect(html).toContain('>ci<');
});
});
test('shows gridlines and axis ticks by default', () => {
const { echartOptions } = transformProps(createTestChartProps({}));
const xAxis = echartOptions.xAxis as any;
const yAxis = echartOptions.yAxis as any;
expect(yAxis.splitLine.show).toBe(true);
expect(yAxis.axisTick.show).toBe(true);
// Left to ECharts, which draws no ticks on a banded category axis. Forcing
// true would add ticks the chart does not have today.
expect(xAxis.axisTick.show).toBe('auto');
});
test('hides gridlines without touching the minor split lines', () => {
const { echartOptions } = transformProps(
createTestChartProps({ formData: { gridlines: false } }),
);
const yAxis = echartOptions.yAxis as any;
expect(yAxis.splitLine.show).toBe(false);
expect(yAxis.minorSplitLine.show).toBe(DEFAULT_FORM_DATA.minorSplitLine);
expect(yAxis.axisTick.show).toBe(true);
});
test('leaves the category axis split lines alone until gridlines are turned off', () => {
const shown = transformProps(createTestChartProps({}));
// Writing show:true here would draw gridlines on axis types that default to
// none, so the key is only ever added to hide them.
expect((shown.echartOptions.xAxis as any).splitLine).toBeUndefined();
const hidden = transformProps(
createTestChartProps({ formData: { gridlines: false } }),
);
expect((hidden.echartOptions.xAxis as any).splitLine.show).toBe(false);
});
test('hides the ticks on both axes', () => {
const { echartOptions } = transformProps(
createTestChartProps({ formData: { axisTicks: false } }),
);
expect((echartOptions.yAxis as any).axisTick.show).toBe(false);
expect((echartOptions.xAxis as any).axisTick.show).toBe(false);
expect((echartOptions.yAxis as any).splitLine.show).toBe(true);
});
test('keeps gridlines and ticks off on a compact chart even when both are enabled', () => {
const { echartOptions } = transformProps(
createTestChartProps({
height: TIMESERIES_CONSTANTS.compactChartHeight - 1,
formData: { gridlines: true, axisTicks: true },
}),
);
const yAxis = echartOptions.yAxis as any;
expect(yAxis.splitLine.show).toBe(false);
expect(yAxis.axisTick.show).toBe(false);
});
test('applies gridlines to the value axis after a horizontal orientation swaps it', () => {
const { echartOptions } = transformProps(
createTestChartProps({
formData: {
orientation: OrientationType.Horizontal,
gridlines: false,
},
}),
);
// The transform swaps the axes for a horizontal chart, so the value axis —
// and the gridlines belonging to it — end up on xAxis.
expect((echartOptions.xAxis as any).splitLine.show).toBe(false);
});
@@ -312,7 +312,7 @@ export function handleComponentDrop(dropResult: DropResult) {
source &&
!(
// ensure it has moved
(destination.id === source.id && destination.index === source.index)
destination.id === source.id && destination.index === source.index
)
) {
dispatch(moveComponent(dropResult));
@@ -117,7 +117,7 @@ const StyledDiv = styled.div`
${
isMobileConsumptionEnabled()
? `@media (max-width: ${theme.screenSMMax}px) {
[data-test='slice-header'] .header-title {
.slice-header .header-title {
-webkit-line-clamp: unset;
display: block;
white-space: normal;
@@ -208,6 +208,18 @@ test('Should render', () => {
expect(screen.getByTestId('slice-header')).toBeInTheDocument();
});
test('Should expose a class hook, not just data-test, for fullscreen styling', () => {
const props = createProps();
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
// The production build strips data-test attributes, so CSS that targets the
// header must hang off a class instead.
expect(screen.getByTestId('slice-header')).toHaveClass('slice-header');
});
test('Should render - default props', () => {
const props = createProps();
@@ -270,7 +270,11 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
);
return (
<ChartHeaderStyles data-test="slice-header" ref={ref}>
<ChartHeaderStyles
className="slice-header"
data-test="slice-header"
ref={ref}
>
<div className="header-title" ref={headerRef}>
<Tooltip title={headerTooltip}>
{/* this div ensures the hover event triggers correctly and prevents flickering */}
@@ -19,20 +19,13 @@
import { css, SupersetTheme } from '@apache-superset/core/theme';
export const fullscreenStyles = (theme: SupersetTheme) => css`
[data-test='dashboard-component-chart-holder']:fullscreen {
.dashboard-component-chart-holder:fullscreen {
background-color: ${theme.colorBgBase};
width: 100vw;
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: ${theme.sizeUnit * 4}px;
overflow: visible;
position: relative;
pointer-events: auto;
z-index: ${theme.zIndexPopupBase};
opacity: 1;
visibility: visible;
/* Ensure children take up available space */
.dashboard-chart,
@@ -58,13 +51,8 @@ export const fullscreenStyles = (theme: SupersetTheme) => css`
}
}
/* Interaction and Header fixes */
[data-test='dashboard-component-chart-holder']:fullscreen * {
pointer-events: auto;
}
[data-test='dashboard-component-chart-holder']:fullscreen
[data-test='slice-header'] {
/* Keep the header above the chart it shares the fullscreen layer with */
.dashboard-component-chart-holder:fullscreen .slice-header {
z-index: ${theme.zIndexPopupBase};
position: relative;
}
@@ -337,13 +337,17 @@ const ChartHolder = ({
)}
>
<AntdThemeProvider
getPopupContainer={(triggerNode: HTMLElement) =>
document.fullscreenElement
? (triggerNode?.closest?.(
'[data-test="dashboard-component-chart-holder"]',
) as HTMLElement) || document.body
: document.body
}
getPopupContainer={(triggerNode?: HTMLElement) => {
// Only the fullscreen element's subtree is painted, so popups
// have to be portaled into it rather than to document.body.
// Resolve it directly instead of matching a selector: the
// production build strips data-test attributes.
const fullscreenElement =
document.fullscreenElement as HTMLElement | null;
return triggerNode && fullscreenElement?.contains(triggerNode)
? fullscreenElement
: document.body;
}}
>
{!editMode && (
<AnchorLink
@@ -97,3 +97,29 @@ test('does not render DeckglLayerVisibilityTooltip for standard filter type', ()
screen.queryByTestId('deckgl-layer-visibility-tooltip-icon'),
).not.toBeInTheDocument();
});
test('does not mark a defaultToFirstItem-only filter as required', () => {
render(
<FilterControl
filter={{
...nativeFilter,
controlValues: { defaultToFirstItem: true },
}}
onFilterSelectionChange={jest.fn()}
/>,
);
expect(screen.queryByText('*')).not.toBeInTheDocument();
});
test('marks an enableEmptyFilter filter as required', () => {
render(
<FilterControl
filter={{
...nativeFilter,
controlValues: { enableEmptyFilter: true },
}}
onFilterSelectionChange={jest.fn()}
/>,
);
expect(screen.getByText('*')).toBeInTheDocument();
});
@@ -65,9 +65,7 @@ const FilterControl = ({
isFilterInScope(filter) &&
checkIsMissingRequiredValue(filter, filter.dataMask?.filterState);
const validateStatus = isMissingRequiredValue ? 'error' : undefined;
const isRequired =
!!filter.controlValues?.enableEmptyFilter ||
!!filter.controlValues?.defaultToFirstItem;
const isRequired = !!filter.controlValues?.enableEmptyFilter;
const inverseSelection = !!filter.controlValues?.inverseSelection;
const {
@@ -211,6 +211,16 @@ test('checkIsMissingRequiredValue returns false for non-required filter with und
expect(checkIsMissingRequiredValue(filter, filterState)).toBe(false);
});
test('checkIsMissingRequiredValue returns false when only defaultToFirstItem is set', () => {
const filter = createFilter('test-filter', {
enableEmptyFilter: false,
controlValues: { defaultToFirstItem: true },
});
expect(checkIsMissingRequiredValue(filter, { value: null })).toBe(false);
expect(checkIsMissingRequiredValue(filter, { value: undefined })).toBe(false);
});
test('checkIsMissingRequiredValue returns falsy for filter without controlValues', () => {
const filter = { id: 'test-filter' } as Filter;
const filterState: FilterState = { value: undefined };
@@ -299,6 +309,48 @@ test('checkIsApplyDisabled returns true when required filter is missing value in
);
});
test('checkIsApplyDisabled enables Apply after clearing a cascading defaultToFirstItem child', () => {
// Regression: a child filter that is dependent on a parent and configured with
// "Select first filter value by default" but NOT "Filter value is required"
// must stay clearable — clearing it may not disable Apply.
const parent = createFilter('parent', {
enableEmptyFilter: true,
controlValues: { defaultToFirstItem: true },
});
const child = createFilter('child', {
enableEmptyFilter: false,
controlValues: { defaultToFirstItem: true },
});
const dataMaskSelected: DataMaskStateWithId = {
parent: {
id: 'parent',
filterState: { value: ['USA'] },
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
},
child: {
id: 'child',
filterState: { value: null },
extraFormData: {},
},
};
const dataMaskApplied: DataMaskStateWithId = {
parent: {
id: 'parent',
filterState: { value: ['USA'] },
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
},
child: {
id: 'child',
filterState: { value: ['CA'] },
extraFormData: createExtraFormDataWithFilter('state', ['CA']),
},
};
expect(
checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, [parent, child]),
).toBe(false);
});
test('checkIsApplyDisabled enables Apply when Selected has a filter value not yet in Applied', () => {
// Regression: when a required filter's default isn't applied (Applied missing
// the entry) and the user types a value, Selected gains an entry Applied
@@ -48,9 +48,10 @@ export const checkIsMissingRequiredValue = (
filter: FilterElement,
filterState?: FilterState,
) => {
const isRequired =
!!filter.controlValues?.enableEmptyFilter ||
!!filter.controlValues?.defaultToFirstItem;
// Only `enableEmptyFilter` ("Filter value is required") makes a value
// mandatory. `defaultToFirstItem` merely seeds an initial selection, so a
// filter cleared by the user must stay clearable, with Apply enabled.
const isRequired = !!filter.controlValues?.enableEmptyFilter;
if (!isRequired) return false;
+1 -1
View File
@@ -126,7 +126,7 @@ function fillNativeFilters(
!(
// Treat all-null arrays (range filters use [null, null] as their
// canonical cleared value) and empty arrays as "no value".
(Array.isArray(loadedValue) && loadedValue.every(v => v === null))
Array.isArray(loadedValue) && loadedValue.every(v => v === null)
);
const loadedHasExtraFormData =
!!loaded?.extraFormData && Object.keys(loaded.extraFormData).length > 0;
@@ -28,14 +28,6 @@ const Wrapper = styled.div`
flex-direction: column;
height: 100%;
.ant-tabs {
height: 100%;
}
.ant-tabs-body {
height: 100%;
}
.ant-tabs-content {
display: flex;
flex-direction: column;
@@ -88,7 +80,12 @@ export const ResultsPaneOnDashboard = ({
return (
<Wrapper>
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey} items={items} />
<Tabs
fullHeight
activeKey={activeTabKey}
onChange={setActiveTabKey}
items={items}
/>
</Wrapper>
);
};
@@ -25,9 +25,15 @@ import {
} from 'spec/helpers/testing-library';
import { ChartMetadata, ChartPlugin, VizType } from '@superset-ui/core';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import Tabs from '@superset-ui/core/components/Tabs';
import { ResultsPaneOnDashboard } from '../components';
import { createResultsPaneOnDashboardProps } from './fixture';
jest.mock('@superset-ui/core/components/Tabs', () => {
const actual = jest.requireActual('@superset-ui/core/components/Tabs');
return { __esModule: true, ...actual, default: jest.fn(actual.default) };
});
beforeAll(() => {
setupAGGridModules();
});
@@ -106,6 +112,8 @@ describe('ResultsPaneOnDashboard', () => {
expect(
await findByText('No results were returned for this query'),
).toBeVisible();
const [tabsProps] = (Tabs as unknown as jest.Mock).mock.calls[0];
expect(tabsProps).toEqual(expect.objectContaining({ fullHeight: true }));
});
test('render errorMessage', async () => {
@@ -29,7 +29,9 @@ import { ControlFormItemComponents } from './ControlForm';
* Column formatting configs.
*/
export type ColumnConfig = {
[key in SharedColumnConfigProp]?: (typeof SHARED_COLUMN_CONFIG_PROPS)[key]['value'];
[
key in SharedColumnConfigProp
]?: (typeof SHARED_COLUMN_CONFIG_PROPS)[key]['value'];
} & Record<string, StrictJsonValue>;
/**
@@ -951,3 +951,167 @@ test('filters the subject select by column verbose_name as well as column_name',
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
});
const COLUMN_VALUES_ENDPOINT =
'glob:*/api/v1/datasource/*/column/value/values/*';
let columnValues: { result: unknown[]; limit: number } = {
result: [],
limit: 10000,
};
fetchMock.get(COLUMN_VALUES_ENDPOINT, () => columnValues);
const setupWithFilterValues = (result: unknown[], limit = 10000) => {
columnValues = { result, limit };
const onChange = jest.fn();
const validHandler = jest.fn();
const spy = jest.spyOn(redux, 'useSelector');
spy.mockReturnValue({});
const props = {
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: Operators.In,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.In].operation,
comparator: [],
clause: Clauses.Where,
}),
onChange,
options,
datasource: {
...TestDataset,
columns: [{ column_name: 'value', type: 'VARCHAR', id: 3 }],
filter_select: true,
},
partitionColumn: 'test',
validHandler,
};
render(
<AdhocFilterEditPopoverSimpleTabContent {...(props as unknown as Props)} />,
);
return props;
};
const openComparator = async () => {
const comparator = screen.getByRole('combobox', {
name: 'Comparator option',
});
userEvent.click(comparator);
return comparator;
};
test('loads comparator values from the server', async () => {
setupWithFilterValues(['alpha', 'beta']);
await openComparator();
expect(await screen.findByTitle('alpha')).toBeInTheDocument();
});
test('sends the typed text to the server rather than filtering the loaded page', async () => {
// The loaded page is bounded, so matching client-side cannot reach a value
// beyond the row limit. The search has to reach the database.
setupWithFilterValues(['alpha']);
const comparator = await openComparator();
userEvent.type(comparator, 'gamma');
await waitFor(
() => {
const searched = fetchMock.callHistory
.calls(COLUMN_VALUES_ENDPOINT)
.map(call => String(call.url));
expect(searched.some(url => url.includes('q=gamma'))).toBe(true);
},
{ timeout: 3000 },
);
});
test('lets a value the server did not return still be selected', async () => {
// Even with server-side search a match can fall outside the page; typing the
// exact value has to remain a way through.
setupWithFilterValues([]);
const comparator = await openComparator();
userEvent.type(comparator, 'not-in-the-page');
expect(await screen.findByTitle('not-in-the-page')).toBeInTheDocument();
});
test('does not query for values when the dataset disables them', async () => {
fetchMock.clearHistory();
setup({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: Operators.In,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.In].operation,
comparator: [],
clause: Clauses.Where,
}),
});
await openComparator();
expect(fetchMock.callHistory.calls(COLUMN_VALUES_ENDPOINT)).toHaveLength(0);
});
test('stores the picked value, not the option object', async () => {
// AsyncSelect is labelInValue: taking its argument at face value puts
// {label, value} into the comparator, and the engine then fails to render it
// as a literal.
const props = setupWithFilterValues(['Michael']);
await openComparator();
userEvent.click(await screen.findByTitle('Michael'));
await waitFor(() => expect(props.onChange).toHaveBeenCalled());
const [filter] = props.onChange.mock.calls.at(-1);
expect(filter.comparator).toEqual(['Michael']);
});
test('can remove a value that was saved earlier', async () => {
// Reopening the popover restores the comparator from the saved filter, and
// the value is not in the freshly loaded page. Removing it has to still work.
columnValues = { result: [], limit: 10000 };
const onChange = jest.fn();
const validHandler = jest.fn();
jest.spyOn(redux, 'useSelector').mockReturnValue({});
render(
<AdhocFilterEditPopoverSimpleTabContent
{...({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: Operators.In,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.In].operation,
comparator: ['Michael'],
clause: Clauses.Where,
}),
onChange,
options,
datasource: {
...TestDataset,
columns: [{ column_name: 'value', type: 'VARCHAR', id: 3 }],
filter_select: true,
},
partitionColumn: 'test',
validHandler,
} as unknown as Props)}
/>,
);
// Remove it the way a user does: the tag's own close control.
userEvent.click(await screen.findByLabelText('close'));
await waitFor(() => expect(onChange).toHaveBeenCalled());
const [filter] = onChange.mock.calls.at(-1);
expect(filter.comparator).toEqual([]);
});
test('says the list is partial when the server capped it', async () => {
setupWithFilterValues(['alpha', 'beta'], 2);
await openComparator();
expect(
await screen.findByText(/Only the first 2 values are listed/),
).toBeInTheDocument();
});
test('does not say the list is partial when it is complete', async () => {
setupWithFilterValues(['alpha', 'beta'], 10000);
await openComparator();
expect(await screen.findByTitle('alpha')).toBeInTheDocument();
expect(screen.queryByText(/Only the first/)).not.toBeInTheDocument();
});
@@ -16,13 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FC, ChangeEvent, useEffect, useState, useRef } from 'react';
import {
FC,
ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
useRef,
} from 'react';
import {
AsyncSelect,
Input,
InputRef,
Select,
Tooltip,
type AsyncSelectRef,
type LabeledValue,
type SelectOptionsTypePage,
type SelectValue,
} from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation';
@@ -57,7 +69,7 @@ import { useDatePickerInAdhocFilter } from '../utils';
import { useDefaultTimeFilter } from '../../DateFilterControl/utils';
import { Clauses, ExpressionTypes } from '../types';
const SelectWithLabel = styled(Select)<{ labelText: string }>`
const SelectWithLabel = styled(AsyncSelect)<{ labelText: string }>`
.ant-select-content::after {
content: ${({ labelText }) => labelText || '\\A0'};
display: inline-block;
@@ -67,6 +79,30 @@ const SelectWithLabel = styled(Select)<{ labelText: string }>`
}
`;
// The server answers with one bounded page, not an offset window: paging would
// need a stable ORDER BY, and ordering a high-cardinality column is the full
// scan this search exists to avoid. A page size no response can reach keeps
// AsyncSelect from asking for a second page.
const COMPARATOR_PAGE_SIZE = 1_000_000;
const toLabeledValue = (value: unknown): LabeledValue => ({
value: value as LabeledValue['value'],
label: optionLabel(value as null | number | boolean | string),
});
// The reverse of toLabeledValue: what AsyncSelect emits is labelled, and the
// comparator has to be the raw value or the engine cannot render it as a
// literal.
const unwrapComparator = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(unwrapComparator);
}
if (value !== null && typeof value === 'object' && 'value' in value) {
return (value as LabeledValue).value;
}
return value;
};
export interface SimpleExpressionType {
expressionType: keyof typeof ExpressionTypes;
column: ColumnMeta;
@@ -347,11 +383,9 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
} = useSimpleTabFilterProps(props);
const [comparator, setComparator] = useState(props.adhocFilter.comparator);
const comparatorInputRef = useRef<InputRef | null>(null);
const [suggestions, setSuggestions] = useState<
Record<'label' | 'value', any>[]
>([]);
const [loadingComparatorSuggestions, setLoadingComparatorSuggestions] =
useState<boolean>(false);
const comparatorSelectRef = useRef<AsyncSelectRef>(null);
const [loadedOptionCount, setLoadedOptionCount] = useState(0);
const [optionsTruncated, setOptionsTruncated] = useState(false);
const [hasFocusedComparator, setHasFocusedComparator] =
useState<boolean>(false);
@@ -387,18 +421,8 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
/>
);
const getOptionsRemaining = () => {
// if select is multi/value is array, we show the options not selected
const valuesFromSuggestionsLength = Array.isArray(comparator)
? comparator.filter(v => suggestions.includes(v)).length
: 0;
return suggestions ? suggestions.length - valuesFromSuggestionsLength : 0;
};
const createSuggestionsPlaceholder = () => {
const optionsRemaining = getOptionsRemaining();
const placeholder = t('%s option(s)', optionsRemaining);
return optionsRemaining ? placeholder : '';
};
const createSuggestionsPlaceholder = () =>
loadedOptionCount ? t('%s option(s)', loadedOptionCount) : '';
const handleSubjectChange = (subject: string) => {
setComparator(undefined);
@@ -455,21 +479,63 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
operatorId !== undefined &&
DISABLE_INPUT_OPERATORS.includes(operatorId as Operators);
const canSuggestComparatorValues = Boolean(
subjectString &&
props.datasource?.filter_select &&
props.adhocFilter.clause !== Clauses.Having,
);
const hasComparatorOptions =
(operatorId && MULTI_OPERATORS.has(operatorId as Operators)) ||
suggestions.length > 0;
canSuggestComparatorValues;
// AsyncSelect is labelInValue, so the value it is given has to be labelled
// too. Handed a bare value it still renders, but `handleOnDeselect` then
// compares `element.value` against entries that have no `.value`, matches
// nothing, and the tag cannot be removed.
//
// Memoised because AsyncSelect resets its internal selection whenever the
// identity of `value` changes. A fresh array every render would wipe out
// each pick as soon as it was made.
const comparatorSelectValue = useMemo(
() =>
Array.isArray(comparator)
? comparator.map(toLabeledValue)
: isDefined(comparator) && comparator !== ''
? toLabeledValue(comparator)
: undefined,
[comparator],
);
const handleComparatorChange = useCallback(
(value: unknown) => {
onComparatorChange(unwrapComparator(value) as string);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[props.adhocFilter, props.onChange],
);
const comparatorSelectProps = {
allowClear: true,
allowNewOptions: true,
ariaLabel: t('Comparator option'),
pageSize: COMPARATOR_PAGE_SIZE,
// A capped list reads as the whole set unless it says otherwise, so an
// absent value looks like a value that does not exist. Only shown when the
// list is actually cut short.
helperText: optionsTruncated
? t(
'Only the first %s values are listed. Type to search all of them, ' +
'or enter a value that is not listed.',
loadedOptionCount,
)
: undefined,
mode:
operatorId && MULTI_OPERATORS.has(operatorId as Operators)
? ('multiple' as const)
: ('single' as const),
loading: loadingComparatorSuggestions,
value: comparator as SelectValue,
onChange: onComparatorChange,
value: comparatorSelectValue as SelectValue,
onChange: handleComparatorChange,
notFoundContent: t('Type a value here'),
placeholder: createSuggestionsPlaceholder(),
};
@@ -495,76 +561,89 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
onChange: onDatePickerChange,
});
useEffect(() => {
const refreshComparatorSuggestions = () => {
const { datasource } = props;
const col = props.adhocFilter.subject;
const having = props.adhocFilter.clause === Clauses.Having;
// Element-level array operators (Contains any / Contains all) search inside
// the array, so suggest individual elements; whole-array operators (=, In, …)
// keep the default distinct-array suggestions.
const arrayElements =
props.adhocFilter.operatorId === Operators.ContainsAny ||
props.adhocFilter.operatorId === Operators.ContainsAll;
if (col && datasource && datasource.filter_select && !having) {
const controller = new AbortController();
const { signal } = controller;
if (loadingComparatorSuggestions) {
controller.abort();
}
// Element-level array operators (Contains any / Contains all) search
// inside the array, so suggest individual elements; whole-array
// operators (=, In, …) keep the default distinct-array suggestions.
const { operatorId } = props.adhocFilter;
const arrayElements =
operatorId === Operators.ContainsAny ||
operatorId === Operators.ContainsAll;
setLoadingComparatorSuggestions(true);
SupersetClient.get({
signal,
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/${
arrayElements ? '?array_elements=true' : ''
}`,
})
.then(({ json }) => {
setSuggestions(
json.result.map((suggestion: unknown) => {
// Complex column values arrive as JS arrays or objects: whole
// arrays for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple
// objects for nested-container columns (e.g. {"a": ["x","y"]}).
// A raw array/object is neither a valid single-select value
// (antd collapses an array to its first element) nor renderable
// as a React child (an object throws). Render it as its literal
// string, which is also exactly what the backend's
// parse_array_literal expects for the whole-array operators.
if (suggestion !== null && typeof suggestion === 'object') {
const literal = JSON.stringify(suggestion);
return { value: literal, label: literal };
}
return {
value: suggestion as null | number | boolean | string,
label: optionLabel(
suggestion as null | number | boolean | string,
),
};
}),
);
setLoadingComparatorSuggestions(false);
})
.catch(() => {
setSuggestions([]);
setLoadingComparatorSuggestions(false);
});
// AsyncSelect throws away every loaded option when the identity of its
// `options` callback changes, so this depends on plain values rather than on
// `props.datasource`, whose identity the parent does not guarantee.
const datasourceType = props.datasource?.type;
const datasourceId = props.datasource?.id;
const loadComparatorOptions = useCallback(
async (search: string): Promise<SelectOptionsTypePage> => {
const col = subjectString;
if (!col || !canSuggestComparatorValues) {
return { data: [], totalCount: 0 };
}
};
if (!datePicker) {
refreshComparatorSuggestions();
}
// loadingComparatorSuggestions intentionally omitted - set inside effect, would cause infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
props.adhocFilter.subject,
props.adhocFilter.clause,
props.adhocFilter.operatorId,
props.datasource,
datePicker,
]);
const params = new URLSearchParams();
if (arrayElements) {
params.set('array_elements', 'true');
}
if (search) {
params.set('q', search);
}
const query = params.toString();
try {
const { json } = await SupersetClient.get({
endpoint:
`/api/v1/datasource/${datasourceType}/${datasourceId}` +
`/column/${encodeURIComponent(col)}/values/${query ? `?${query}` : ''}`,
});
const data = json.result.map((suggestion: unknown) => {
// Complex column values arrive as JS arrays or objects: whole arrays
// for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple objects for
// nested-container columns (e.g. {"a": ["x","y"]}). A raw
// array/object is neither a valid single-select value (antd collapses
// an array to its first element) nor renderable as a React child (an
// object throws). Render it as its literal string, which is also
// exactly what the backend's parse_array_literal expects for the
// whole-array operators.
if (suggestion !== null && typeof suggestion === 'object') {
const literal = JSON.stringify(suggestion);
return { value: literal, label: literal };
}
return {
value: suggestion as null | number | boolean | string,
label: optionLabel(suggestion as null | number | boolean | string),
};
});
setLoadedOptionCount(data.length);
setOptionsTruncated(isDefined(json.limit) && data.length >= json.limit);
// The count has to exceed what was returned. AsyncSelect treats
// `loaded >= totalCount` as "that is every value", sets allValuesLoaded
// and from then on serves searches by filtering the loaded page
// client-side -- which is the behaviour this whole change exists to
// replace. Pagination is held off by COMPARATOR_PAGE_SIZE instead.
return { data, totalCount: data.length + 1 };
} catch {
setLoadedOptionCount(0);
setOptionsTruncated(false);
return { data: [], totalCount: 0 };
}
},
[
subjectString,
canSuggestComparatorValues,
datasourceType,
datasourceId,
arrayElements,
],
);
// Options are cached per search term inside AsyncSelect; a different column
// or a switch to element-level suggestions invalidates all of them.
useEffect(() => {
comparatorSelectRef.current?.clearCache();
}, [subjectString, arrayElements]);
useEffect(() => {
if (isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes)) {
@@ -670,11 +749,12 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
}
>
<SelectWithLabel
ref={comparatorSelectRef}
css={css`
margin-top: ${theme.marginXS}px;
`}
labelText={labelText}
options={suggestions}
options={loadComparatorOptions}
{...comparatorSelectProps}
/>
</Tooltip>
@@ -16,10 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useState } from 'react';
import {
AppSection,
Behavior,
ChartProps,
type DataMask,
type FilterState,
} from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
@@ -877,10 +879,41 @@ describe('SelectFilterPlugin', () => {
expect(await screen.findByTitle('brand-new')).toBeInTheDocument();
});
test('does not show create option when searchAllOptions is true', () => {
test('says the list is capped when it hits the row limit', async () => {
// 3 rows of data against a limit of 3: the user is looking at a page, not
// at every value the column has.
getWrapper({ rowLimit: 3 });
userEvent.click(screen.getAllByRole('combobox')[0]);
expect(
await screen.findByText(/Only the first 3 values are listed/),
).toBeInTheDocument();
});
test('offers the ways out that the filter actually supports', async () => {
getWrapper({ rowLimit: 3, creatable: true, searchAllOptions: true });
userEvent.click(screen.getAllByRole('combobox')[0]);
expect(
await screen.findByText(/Type to search all of them/),
).toBeInTheDocument();
expect(
screen.getByText(/You can enter a value that is not listed/),
).toBeInTheDocument();
});
test('says nothing when the whole column fits under the limit', async () => {
getWrapper();
userEvent.click(screen.getAllByRole('combobox')[0]);
expect(await screen.findByRole('combobox')).toBeInTheDocument();
expect(screen.queryByText(/Only the first/)).not.toBeInTheDocument();
});
test('shows create option when searchAllOptions is true', async () => {
// Server-side search returns a bounded page, so a value that exists in the
// data can still be missing from the dropdown. Suppressing the create
// option there leaves the user with no way to apply it at all.
getWrapper({ creatable: true, searchAllOptions: true });
userEvent.type(screen.getByRole('combobox'), 'brand-new');
expect(screen.queryByTitle('brand-new')).not.toBeInTheDocument();
expect(await screen.findByTitle('brand-new')).toBeInTheDocument();
});
});
@@ -1393,6 +1426,153 @@ test('preserves dependent filter value restored from URL when it exists in data'
});
});
test('keeps a dependent filter empty after the user clears it', async () => {
// Regression: a dependent filter with "Select first filter value by default"
// used to re-apply the first option as soon as the cleared value round-tripped
// through the filter bar, making it impossible to clear.
jest.useRealTimers();
const setDataMaskMock = jest.fn();
const testProps = {
...selectMultipleProps,
formData: {
...selectMultipleProps.formData,
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: true,
// Non-empty extraFormData is what marks this filter as dependent
extraFormData: {
filters: [{ col: 'region', op: 'IN', val: ['North America'] }],
},
},
};
// The filter bar feeds every dispatched dataMask back into the plugin as the
// controlled `filterState` prop; the harness reproduces that round-trip.
const ControlledSelectFilter = () => {
const [filterState, setFilterState] = useState<FilterState>({
value: ['boy'],
});
const handleDataMask = useCallback((dataMask: DataMask) => {
setDataMaskMock(dataMask);
setFilterState(prev => ({ ...prev, ...dataMask.filterState }));
}, []);
return (
// @ts-expect-error
<SelectFilterPlugin
// @ts-expect-error
{...transformProps({ ...testProps, filterState })}
setDataMask={handleDataMask}
showOverflow={false}
/>
);
};
render(<ControlledSelectFilter />, {
useRedux: true,
initialState: {
nativeFilters: {
filters: {
'test-filter': {
name: 'Test Filter',
},
},
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: { value: ['boy'] },
},
},
},
});
userEvent.click(
screen.getByRole('img', {
name: /close-circle/i,
hidden: true,
}),
);
await waitFor(() =>
expect(setDataMaskMock).toHaveBeenLastCalledWith(
expect.objectContaining({
extraFormData: {},
filterState: expect.objectContaining({ value: null }),
}),
),
);
// Let the re-validation effects settle: the value must not come back
await act(async () => {
await Promise.resolve();
});
expect(setDataMaskMock).toHaveBeenLastCalledWith(
expect.objectContaining({
filterState: expect.objectContaining({ value: null }),
}),
);
expect(screen.queryByTitle('boy')).not.toBeInTheDocument();
});
test('keeps a dependent filter empty when it mounts with a cleared value', async () => {
// Regression: after a reload the cleared state comes back as `value: null` on
// a fresh component, so the in-memory "user cleared this" ref is gone. The
// first item must still not be re-applied.
const setDataMaskMock = jest.fn();
const testProps = {
...selectMultipleProps,
formData: {
...selectMultipleProps.formData,
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: true,
extraFormData: {
filters: [{ col: 'region', op: 'IN', val: ['North America'] }],
},
},
filterState: { value: null },
};
render(
// @ts-expect-error
<SelectFilterPlugin
// @ts-expect-error
{...transformProps(testProps)}
setDataMask={setDataMaskMock}
showOverflow={false}
/>,
{
useRedux: true,
initialState: {
nativeFilters: {
filters: {
'test-filter': {
name: 'Test Filter',
},
},
},
dataMask: {
'test-filter': {
extraFormData: {},
filterState: { value: null },
},
},
},
},
);
// Let the re-validation effect run before asserting it did nothing
await act(async () => {
await Promise.resolve();
});
expect(setDataMaskMock).toHaveBeenCalled();
expect(setDataMaskMock).not.toHaveBeenCalledWith(
expect.objectContaining({
filterState: expect.objectContaining({ value: ['boy'] }),
}),
);
});
test('resets dependent filter to first item when value does not exist in data', async () => {
const setDataMaskMock = jest.fn();
const testProps = {
@@ -157,7 +157,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const [col] = groupby;
const [initialColtypeMap] = useState(coltypeMap);
const [search, setSearch] = useState('');
const prevDataRef = useRef(data);
const userClearedRef = useRef(false);
const [dataMask, dispatchDataMask] = useImmerReducer(reducer, {
extraFormData: {},
@@ -272,7 +271,10 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
type: 'ownState',
ownState: {
coltypeMap: initialColtypeMap,
search,
// The dropdown offers `stripSurroundingQuotes(search)` as the
// creatable option, so the server has to be asked for the same
// string or the two disagree about what was searched for.
search: stripSurroundingQuotes(search).trim(),
},
});
}
@@ -282,8 +284,10 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const handleBlur = useCallback(() => {
unsetFocusedFilter();
onSearch('');
}, [onSearch, unsetFocusedFilter]);
if (search) {
onSearch('');
}
}, [onSearch, search, unsetFocusedFilter]);
const handleChange = useCallback(
(value?: SelectValue | number | string) => {
@@ -305,6 +309,25 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
? t('No data')
: tn('%s option', '%s options', data.length, data.length);
// A capped list reads as the whole set, so a value sitting past the row
// limit looks like a value that does not exist. Each sentence is only added
// when it is actually true of this filter's configuration.
const rowLimit = Number(formData.rowLimit) || 0;
const helperText = useMemo(() => {
if (!rowLimit || data.length < rowLimit) {
return undefined;
}
return [
t('Only the first %s values are listed.', data.length),
searchAllOptions ? t('Type to search all of them.') : undefined,
creatable !== false
? t('You can enter a value that is not listed.')
: undefined,
]
.filter(Boolean)
.join(' ');
}, [creatable, data.length, rowLimit, searchAllOptions]);
const formItemExtra = useMemo(() => {
if (filterState.validateMessage) {
return (
@@ -337,7 +360,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const unquotedSearch = stripSurroundingQuotes(search);
if (
unquotedSearch &&
!searchAllOptions &&
creatable !== false &&
!hasOption(unquotedSearch, uniqueOptions, true)
) {
@@ -347,7 +369,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
];
}
return uniqueOptions;
}, [search, uniqueOptions, creatable, searchAllOptions]);
}, [search, uniqueOptions, creatable]);
const sortComparator = useCallback(
(a: LabeledValue, b: LabeledValue) => {
@@ -430,26 +452,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
clearAllTrigger,
]);
useEffect(() => {
const prev = prevDataRef.current;
const curr = data;
const hasDataChanged =
prev?.length !== curr?.length ||
prev?.some((row, i) => {
const prevVal = row[col];
const currVal = curr[i][col];
return typeof prevVal === 'bigint' || typeof currVal === 'bigint'
? prevVal?.toString() !== currVal?.toString()
: prevVal !== currVal;
});
// If data actually changed (e.g., due to parent filter), reset flag
if (hasDataChanged) {
prevDataRef.current = data;
}
}, [data, col]);
useEffect(() => {
if (
filterState.value?.every((value?: any) =>
@@ -462,13 +464,17 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
? (groupby.map(col => data[0][col]) as string[])
: null;
// Skip default value update when clearAllTrigger is active
// Skip default value update when clearAllTrigger is active.
// `null` is a persisted "user cleared this" state, as opposed to
// `undefined` for "never set", so it must not be re-defaulted either —
// `userClearedRef` alone would not survive a reload.
if (
!clearAllTrigger &&
defaultToFirstItem &&
!userClearedRef.current &&
Object.keys(formData?.extraFormData || {}).length &&
filterState.value !== undefined &&
filterState.value !== null &&
firstItem !== null &&
filterState.value !== firstItem
) {
@@ -634,7 +640,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
name={formData.nativeFilterId}
allowClear
autoClearSearchValue
allowNewOptions={!searchAllOptions && creatable !== false}
allowNewOptions={creatable !== false}
allowNewOptionsOnPaste={multiSelect && searchAllOptions}
allowSelectAll={!searchAllOptions}
value={multiSelect ? filterState.value || [] : filterState.value}
@@ -643,6 +649,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
showSearch={showSearch}
mode={multiSelect ? 'multiple' : 'single'}
placeholder={placeholderText}
helperText={helperText}
onClear={() => onSearch('')}
onSearch={onSearch}
onBlur={handleBlur}
@@ -117,6 +117,38 @@ describe('Select buildQuery', () => {
]);
});
test('should not sort by the searched column', () => {
// Ordering by a high-cardinality column makes the engine sort every match
// before applying the row limit; the dropdown re-sorts the page anyway.
const queryContext = buildQuery(
{ ...formData, sortAscending: true },
{
ownState: {
search: 'abc',
coltypeMap: { my_col: GenericDataType.String },
},
},
);
const [query] = queryContext.queries;
expect(query.orderby).toEqual([]);
});
test('should keep the sort metric while searching', () => {
// A sort metric decides which rows come back, so dropping it would change
// the result set rather than just its order.
const queryContext = buildQuery(
{ ...formData, sortMetric: 'my_metric', sortAscending: false },
{
ownState: {
search: 'abc',
coltypeMap: { my_col: GenericDataType.String },
},
},
);
const [query] = queryContext.queries;
expect(query.orderby).toEqual([['my_metric', false]]);
});
test('should add text search parameter for numeric to query filter', () => {
const queryContext = buildQuery(formData, {
ownState: {
@@ -54,6 +54,13 @@ const buildQuery: BuildQuery<PluginFilterSelectQueryFormData> = (
}
const sortColumns = sortMetric ? [sortMetric] : columns;
// Sorting by the searched column makes the engine scan and sort every
// match before applying the row limit, which is the dominant cost of
// search-as-you-type on a high-cardinality column. The dropdown re-sorts
// the returned page client-side, so the server sort buys nothing here. A
// sort metric is different: it selects *which* rows come back, so it has
// to stay.
const skipOrderBy = !!search && !sortMetric;
const query: QueryObject[] = [
{
...baseQueryObject,
@@ -61,7 +68,7 @@ const buildQuery: BuildQuery<PluginFilterSelectQueryFormData> = (
metrics: sortMetric ? [sortMetric] : [],
filters: filters.concat(extraFilters),
orderby:
sortMetric || sortAscending !== undefined
!skipOrderBy && (sortMetric || sortAscending !== undefined)
? sortColumns.map(column => [column, !!sortAscending])
: [],
},
+1
View File
@@ -0,0 +1 @@
../superset-frontend/.npmrc
+1 -1
View File
@@ -160,7 +160,7 @@ def migrate_by_id(ids: tuple[int, ...], is_downgrade: bool = False) -> None:
"""
Migrate a subset of charts by IDs.
:param id: Tuple of chart IDs to migrate
:param ids: Tuple of chart IDs to migrate
:param is_downgrade: Whether to downgrade the charts. Default is upgrade.
"""
slices = db.session.query(Slice).filter(Slice.id.in_(ids))
+1
View File
@@ -964,6 +964,7 @@ class AnnotationDatasource(BaseDatasource):
limit: int = 10000,
denormalize_column: bool = False,
array_elements: bool = False,
search: str | None = None,
) -> list[Any]:
raise NotImplementedError()
+12
View File
@@ -278,6 +278,18 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
"database.backend",
"database.allow_multi_catalog",
"columns.advanced_data_type",
# Certification/warning metadata is stored serialized in the ``extra``
# column and surfaced through model properties. Exposing them keeps this
# payload consistent with the datasource serialization used by Explore,
# so clients hydrating from this endpoint don't lose the badges.
"columns.certification_details",
"columns.certified_by",
"columns.is_certified",
"columns.warning_markdown",
"metrics.certification_details",
"metrics.certified_by",
"metrics.is_certified",
"metrics.warning_markdown",
"is_managed_externally",
"uid",
"uuid",
+23 -2
View File
@@ -41,6 +41,9 @@ from superset.views.base_api import BaseSupersetApi, statsd_metrics
logger = logging.getLogger(__name__)
# Cache lifetime for search-filtered column values, in seconds.
SEARCH_CACHE_TIMEOUT = 60
class DatasourceRestApi(BaseSupersetApi):
allow_browser_login = True
@@ -87,6 +90,14 @@ class DatasourceRestApi(BaseSupersetApi):
type: string
name: column_name
description: The name of the column to get values for
- in: query
schema:
type: string
name: q
description: >-
Optional case-insensitive substring; only values containing it are
returned. Lets the client search the full column rather than the
truncated first page.
responses:
200:
description: A List of distinct values for the column
@@ -136,6 +147,10 @@ class DatasourceRestApi(BaseSupersetApi):
# Element-level operators (Contains any / Contains all) request the
# distinct array *elements* rather than distinct whole arrays.
array_elements = parse_boolean_string(request.args.get("array_elements"))
# Server-side search. Without it the client can only match against the
# bounded first page, so a value beyond ``FILTER_SELECT_ROW_LIMIT`` is
# unfindable on a high-cardinality column.
search = (request.args.get("q") or "").strip() or None
# Cache distinct column-value results so a dashboard with many filters
# backed by the same (often heavy) virtual dataset doesn't re-execute
@@ -169,6 +184,7 @@ class DatasourceRestApi(BaseSupersetApi):
"limit": row_limit,
"denorm": denormalize_column,
"elements": array_elements,
"q": search,
"rls": security_manager.get_rls_cache_key(datasource),
"changed_on": str(getattr(datasource, "changed_on", "")),
},
@@ -184,7 +200,7 @@ class DatasourceRestApi(BaseSupersetApi):
logger.debug(
"column-values cache HIT: uid=%s col=%s", datasource.uid, column_name
)
response = self.response(200, result=cached)
response = self.response(200, result=cached, limit=row_limit)
response.headers["X-Cache-Status"] = "HIT"
return response
@@ -194,6 +210,7 @@ class DatasourceRestApi(BaseSupersetApi):
limit=row_limit,
denormalize_column=denormalize_column,
array_elements=array_elements,
search=search,
)
except KeyError:
return self.response(
@@ -225,11 +242,15 @@ class DatasourceRestApi(BaseSupersetApi):
timeout = datasource.cache_timeout or app.config.get(
"CACHE_DEFAULT_TIMEOUT", 300
)
if search:
# Every distinct search term is its own key, so a few users typing
# would otherwise pin one entry per keystroke for the full timeout.
timeout = min(timeout, SEARCH_CACHE_TIMEOUT)
cache_manager.data_cache.set(cache_key, payload, timeout=timeout)
logger.debug(
"column-values cache MISS: uid=%s col=%s", datasource.uid, column_name
)
response = self.response(200, result=payload)
response = self.response(200, result=payload, limit=row_limit)
response.headers["X-Cache-Status"] = "MISS"
return response
+3 -3
View File
@@ -45,9 +45,9 @@ def redefine(
Redefine the foreign key constraint to include the ON DELETE and ON UPDATE
constructs for cascading purposes.
:params foreign_key: The foreign key constraint
:param ondelete: If set, emit ON DELETE <value> when issuing DDL operations
:param onupdate: If set, emit ON UPDATE <value> when issuing DDL operations
:param foreign_key: The foreign key constraint
:param on_delete: If set, emit ON DELETE <value> when issuing DDL operations
:param on_update: If set, emit ON UPDATE <value> when issuing DDL operations
"""
bind = op.get_bind()
+55 -4
View File
@@ -198,6 +198,41 @@ def get_effective_hours_offset(
R_SUFFIX = "__right_suffix"
# Escape character for LIKE patterns built from user-supplied search text.
# Deliberately not a backslash: dialects that escape backslashes when rendering
# string literals would emit a two-character ESCAPE clause, which is a syntax
# error on engines that honour standard-conforming strings.
LIKE_ESCAPE_CHAR = "!"
def escape_like_pattern(value: str) -> str:
"""
Neutralize LIKE wildcards in user-supplied search text.
Without this a user typing ``%`` or ``_`` would match every row, which is
both wrong and, on a large table, a scan the search was meant to avoid.
"""
return (
value.replace(LIKE_ESCAPE_CHAR, LIKE_ESCAPE_CHAR * 2)
.replace("%", f"{LIKE_ESCAPE_CHAR}%")
.replace("_", f"{LIKE_ESCAPE_CHAR}_")
)
def build_like_predicate(
expr: ColumnElement[Any],
search: str,
) -> ColumnElement[Any]:
"""
Build a case-insensitive containment predicate for ``expr``.
``lower(expr) LIKE lower('%term%')`` is used rather than ``ILIKE`` because
the latter is not portable across engines.
"""
pattern = f"%{escape_like_pattern(search)}%".lower()
return sa.func.lower(expr).like(pattern, escape=LIKE_ESCAPE_CHAR)
def _normalize_mssql_virtual_dataset_sql(
sql: str, parsed_script: SQLScript, engine: str
) -> str:
@@ -1611,16 +1646,28 @@ class ExtraJSONMixin:
return value
_EXTRA_DICT_CACHE_UNSET = object()
class CertificationMixin:
"""Mixin to add extra certification fields"""
extra = sa.Column(sa.Text, default="{}")
def get_extra_dict(self) -> dict[str, Any]:
try:
return json.loads(self.extra)
except (TypeError, json.JSONDecodeError):
return {}
# Cache the parsed ``extra`` payload on the instance, keyed by the raw
# string it was parsed from, so callers reading multiple
# certification/warning properties off the same object don't each
# trigger their own ``json.loads``. The cache is transient (not a
# mapped column) and self-invalidates whenever ``extra`` changes.
cache_raw = getattr(self, "_extra_dict_cache_raw", _EXTRA_DICT_CACHE_UNSET)
if cache_raw is _EXTRA_DICT_CACHE_UNSET or cache_raw != self.extra:
try:
self._extra_dict_cache = json.loads(self.extra)
except (TypeError, json.JSONDecodeError):
self._extra_dict_cache = {}
self._extra_dict_cache_raw = self.extra
return self._extra_dict_cache
@property
def is_certified(self) -> bool:
@@ -4010,6 +4057,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
limit: int = 10000,
denormalize_column: bool = False,
array_elements: bool = False,
search: str | None = None,
) -> list[Any]:
# denormalize column name before querying for values
# unless disabled in the dataset configuration
@@ -4047,6 +4095,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
.select_from(tbl)
.distinct()
)
if search:
qry = qry.where(build_like_predicate(value_expr, search))
if limit:
qry = qry.limit(limit)
+1 -1
View File
@@ -404,7 +404,7 @@ class SemanticView(AuditMixinNullable, Model):
for dimension in dimensions
},
}
column_formats = {
column_formats: dict[str, str | None] = {
metric.name: metric.d3format for metric in metrics if metric.d3format
}
+1 -1
View File
@@ -333,7 +333,7 @@ class BaseScreenshot:
Computes the thumbnail and caches the result
:param user: If no user is given will use the current context
:param cache: The cache to keep the thumbnail payload
:param cache_key: The cache key to store the thumbnail payload under
:param window_size: The window size from which will process the thumb
:param thumb_size: The final thumbnail size
:param force: Will force the computation even if it's already cached
+26 -18
View File
@@ -20,6 +20,7 @@ import copy
import unittest
from datetime import timedelta
from io import BytesIO
from typing import Any
from unittest.mock import ANY, patch
from zipfile import is_zipfile, ZipFile
@@ -72,6 +73,27 @@ from tests.integration_tests.fixtures.importexport import (
dataset_ui_export,
)
# Fields the dataset ``show`` payload exposes but the ``PUT`` schema doesn't
# accept: audit timestamps plus attributes derived from the model (type
# affinity and the certification/warning metadata stored in ``extra``).
DATASET_READ_ONLY_ITEM_FIELDS = (
"changed_on",
"created_on",
"type_generic",
"certification_details",
"certified_by",
"is_certified",
"warning_markdown",
)
def strip_read_only_fields(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Drop read-only fields so a ``show`` payload can be fed back to ``PUT``."""
for item in items:
for field in DATASET_READ_ONLY_ITEM_FIELDS:
item.pop(field, None)
return items
class TestDatasetApi(SupersetTestCase):
fixture_tables_names = ("ab_permission", "ab_permission_view", "ab_view_menu")
@@ -1316,17 +1338,10 @@ class TestDatasetApi(SupersetTestCase):
rv = self.get_assert_metric(uri, "get")
data = json.loads(rv.data.decode("utf-8"))
for column in data["result"]["columns"]:
column.pop("changed_on", None)
column.pop("created_on", None)
column.pop("type_generic", None)
strip_read_only_fields(data["result"]["columns"])
data["result"]["columns"].append(new_column_data)
for metric in data["result"]["metrics"]:
metric.pop("changed_on", None)
metric.pop("created_on", None)
metric.pop("type_generic", None)
strip_read_only_fields(data["result"]["metrics"])
data["result"]["metrics"].append(new_metric_data)
with freeze_time() as frozen:
@@ -1404,11 +1419,7 @@ class TestDatasetApi(SupersetTestCase):
rv = self.get_assert_metric(uri, "get")
data = json.loads(rv.data.decode("utf-8"))
for column in data["result"]["columns"]:
column.pop("changed_on", None)
column.pop("created_on", None)
column.pop("type_generic", None)
strip_read_only_fields(data["result"]["columns"])
data["result"]["columns"].append(new_column_data)
rv = self.client.put(uri, json={"columns": data["result"]["columns"]})
@@ -1443,10 +1454,7 @@ class TestDatasetApi(SupersetTestCase):
# Get current cols and alter one
rv = self.get_assert_metric(uri, "get")
resp_columns = json.loads(rv.data.decode("utf-8"))["result"]["columns"]
for column in resp_columns:
column.pop("changed_on", None)
column.pop("created_on", None)
column.pop("type_generic", None)
strip_read_only_fields(resp_columns)
resp_columns[0]["groupby"] = False
resp_columns[0]["filterable"] = False
@@ -155,6 +155,7 @@ class TestDatasourceApi(SupersetTestCase):
limit=10000,
denormalize_column=False,
array_elements=False,
search=None,
)
@pytest.mark.usefixtures("app_context", "virtual_dataset")
@@ -170,6 +171,79 @@ class TestDatasourceApi(SupersetTestCase):
)
assert values_for_column_mock.call_args.kwargs["array_elements"] is True
@pytest.mark.usefixtures("app_context", "virtual_dataset")
def test_get_column_values_search_filters_server_side(self):
"""``?q=`` narrows the values in the database rather than client-side,
which is what makes a value beyond the row limit reachable at all."""
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
rv = self.client.get(
f"api/v1/datasource/table/{table.id}/column/col2/values/?q=b"
)
assert rv.status_code == 200
assert json.loads(rv.data.decode("utf-8"))["result"] == ["b"]
@pytest.mark.usefixtures("app_context", "virtual_dataset")
def test_get_column_values_search_is_case_insensitive(self):
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
rv = self.client.get(
f"api/v1/datasource/table/{table.id}/column/col2/values/?q=B"
)
assert rv.status_code == 200
assert json.loads(rv.data.decode("utf-8"))["result"] == ["b"]
@pytest.mark.usefixtures("app_context", "virtual_dataset")
def test_get_column_values_search_escapes_wildcards(self):
"""A literal ``%`` must not be treated as "match everything"."""
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
rv = self.client.get(
f"api/v1/datasource/table/{table.id}/column/col2/values/?q=%25"
)
assert rv.status_code == 200
assert json.loads(rv.data.decode("utf-8"))["result"] == []
@pytest.mark.usefixtures("app_context", "virtual_dataset")
@patch("superset.models.helpers.ExploreMixin.values_for_column")
def test_get_column_values_blank_search_is_ignored(self, values_for_column_mock):
"""Whitespace is not a search term; it must not narrow the list."""
values_for_column_mock.return_value = []
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
self.client.get(
f"api/v1/datasource/table/{table.id}/column/col2/values/?q=%20%20"
)
assert values_for_column_mock.call_args.kwargs["search"] is None
@pytest.mark.usefixtures("app_context", "virtual_dataset")
def test_get_column_values_returns_applied_limit(self):
"""The client needs the limit to tell a short list from a truncated
one, so it can say the list is partial instead of implying it is whole."""
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
rv = self.client.get(f"api/v1/datasource/table/{table.id}/column/col2/values/")
assert rv.status_code == 200
assert json.loads(rv.data.decode("utf-8"))["limit"] == 10000
@pytest.mark.usefixtures("app_context", "virtual_dataset")
@patch("superset.models.helpers.ExploreMixin.values_for_column")
def test_get_column_values_cache_isolated_per_search(self, values_for_column_mock):
"""Search terms must partition the cache; sharing one entry would serve
the results of somebody else's search."""
cache_manager.data_cache.clear()
values_for_column_mock.return_value = ["x"]
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
self.client.get(url)
self.client.get(f"{url}?q=a")
self.client.get(f"{url}?q=b")
self.client.get(f"{url}?q=a")
assert values_for_column_mock.call_count == 3
@pytest.mark.usefixtures("app_context", "virtual_dataset")
@patch("superset.db_engine_specs.base.BaseEngineSpec.denormalize_name")
def test_get_column_values_not_denormalize_column(self, denormalize_name_mock):
@@ -191,6 +265,7 @@ class TestDatasourceApi(SupersetTestCase):
limit=10000,
denormalize_column=True,
array_elements=False,
search=None,
)
@pytest.mark.usefixtures("app_context", "virtual_dataset")
+61
View File
@@ -21,6 +21,7 @@ from unittest.mock import MagicMock, patch
from sqlalchemy.orm.session import Session
from superset import db
from superset.utils import json
def test_put_invalid_dataset(
@@ -214,3 +215,63 @@ def test_handle_filters_args_returns_request_scoped_filters(
fresh_filters = api.datamodel.get_filters.return_value
assert fresh_filters.rest_add_filters.call_count == 2
assert fresh_filters.get_joined_filters.call_count == 2
def test_get_dataset_exposes_certification_metadata(
session: Session,
client: Any,
full_api_access: None,
) -> None:
"""
Dataset API: Test that the show payload exposes the certification and
warning metadata for both columns and metrics.
Regression test for #43279: Explore hydrates its datasource from this
endpoint after a dataset save or swap. Without these fields the certified
and warning badges disappeared until the page was reloaded, because the
Explore bootstrap payload serializes them but this endpoint did not.
"""
from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
from superset.models.core import Database
SqlaTable.metadata.create_all(db.session.get_bind())
extra = json.dumps(
{
"certification": {
"certified_by": "Data Platform",
"details": "Reviewed quarterly",
},
"warning_markdown": "This is a **warning**",
}
)
database = Database(
database_name="my_db",
sqlalchemy_uri="sqlite://",
)
dataset = SqlaTable(
table_name="test_certification_table",
database=database,
columns=[
TableColumn(column_name="ds", type="TIMESTAMP", extra=extra),
TableColumn(
column_name="calculated",
type="INTEGER",
expression="1 + 1",
extra=extra,
),
],
metrics=[SqlMetric(metric_name="cnt", expression="COUNT(*)", extra=extra)],
)
db.session.add(dataset)
db.session.flush()
response = client.get(f"/api/v1/dataset/{dataset.id}")
assert response.status_code == 200
result = response.json["result"]
for item in [*result["columns"], *result["metrics"]]:
assert item["is_certified"] is True
assert item["certified_by"] == "Data Platform"
assert item["certification_details"] == "Reviewed quarterly"
assert item["warning_markdown"] == "This is a **warning**"
+80
View File
@@ -105,6 +105,86 @@ def test_values_for_column(database: Database) -> None:
assert table.values_for_column("a") == [1, None]
@pytest.mark.parametrize(
"raw,expected",
[
("plain", "plain"),
("50%", "50!%"),
("a_b", "a!_b"),
("wow!", "wow!!"),
("!%_", "!!!%!_"),
],
)
def test_escape_like_pattern(raw: str, expected: str) -> None:
"""Wildcards typed by a user are data, not pattern syntax."""
from superset.models.helpers import escape_like_pattern
assert escape_like_pattern(raw) == expected
def test_build_like_predicate_is_case_insensitive_and_escaped() -> None:
import sqlalchemy as sa
from superset.models.helpers import build_like_predicate
compiled = str(
build_like_predicate(sa.column("c"), "50%").compile(
dialect=sa.dialects.registry.load("postgresql")(),
compile_kwargs={"literal_binds": True},
)
).replace("%%", "%")
assert compiled == "lower(c) LIKE '%50!%%' ESCAPE '!'"
def test_values_for_column_search(database: Database) -> None:
"""``search`` narrows the distinct-value query in the database."""
import pandas as pd
from superset.connectors.sqla.models import SqlaTable, TableColumn
table = SqlaTable(
database=database,
schema=None,
table_name="t",
columns=[TableColumn(column_name="a")],
)
with patch(
"pandas.read_sql_query",
return_value=pd.DataFrame({"column_values": ["Alice"]}),
) as read_sql_query:
assert table.values_for_column("a", search="ali") == ["Alice"]
sql = str(read_sql_query.call_args.kwargs["sql"])
assert "LIKE" in sql
assert "'%ali%'" in sql
def test_values_for_column_without_search_has_no_predicate(
database: Database,
) -> None:
"""The unsearched list must stay a plain bounded DISTINCT scan."""
import pandas as pd
from superset.connectors.sqla.models import SqlaTable, TableColumn
table = SqlaTable(
database=database,
schema=None,
table_name="t",
columns=[TableColumn(column_name="a")],
)
with patch(
"pandas.read_sql_query",
return_value=pd.DataFrame({"column_values": ["Alice"]}),
) as read_sql_query:
table.values_for_column("a")
assert "LIKE" not in str(read_sql_query.call_args.kwargs["sql"])
def test_values_for_column_passes_catalog_and_schema(
mocker: MockerFixture,
session: Session,