mirror of
https://github.com/apache/superset.git
synced 2026-08-28 19:11:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89879e1f8f | ||
|
|
3e91eba6ca | ||
|
|
7d8a53ba68 | ||
|
|
5c24f72d92 | ||
|
|
60479fb958 | ||
|
|
4ced5ca35a | ||
|
|
fc26991cd4 | ||
|
|
a5c68c8df9 | ||
|
|
53e76afd70 | ||
|
|
317554dfd0 | ||
|
|
f74b8768b9 | ||
|
|
d997d363e3 | ||
|
|
433aadb68c | ||
|
|
602f430c00 | ||
|
|
9a6f6ee0c0 | ||
|
|
94dd3d049c | ||
|
|
c52cd0af5f | ||
|
|
b3f718da62 | ||
|
|
d18073fb2e | ||
|
|
23b5ed66dc | ||
|
|
d1de3b0cf3 | ||
|
|
23b72d31ca | ||
|
|
7aa94b53cc | ||
|
|
90a5e0074b | ||
|
|
2e5d3b8c73 | ||
|
|
046de3121c | ||
|
|
3b1398d6e3 | ||
|
|
9895bb0d10 | ||
|
|
3658d62f01 | ||
|
|
7ad5726717 |
@@ -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",
|
||||
]
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../superset-frontend/.npmrc
|
||||
@@ -0,0 +1 @@
|
||||
min-release-age=3
|
||||
@@ -77,7 +77,7 @@ module.exports = {
|
||||
// @ant-design/colors and @ant-design/fast-color are allowed through because
|
||||
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
|
||||
// from its CJS output, so babel-jest must transform those files.
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge|content-disposition)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
Generated
+6
-7
@@ -69,7 +69,6 @@
|
||||
"@superset-ui/plugin-chart-world-map": "file:./plugins/plugin-chart-world-map",
|
||||
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
|
||||
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
|
||||
"@types/d3-format": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
@@ -84,7 +83,7 @@
|
||||
"antd": "^6.6.1",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"content-disposition": "^3.0.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
@@ -17451,12 +17450,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
||||
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz",
|
||||
"integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -42974,6 +42973,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@types/d3-format": "^3.0.4",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^4.0.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
@@ -43017,7 +43017,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@types/d3-format": "^3.0.4",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-time": "^3.0.4",
|
||||
|
||||
@@ -146,7 +146,6 @@
|
||||
"@superset-ui/plugin-chart-world-map": "file:./plugins/plugin-chart-world-map",
|
||||
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
|
||||
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
|
||||
"@types/d3-format": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
@@ -161,7 +160,7 @@
|
||||
"antd": "^6.6.1",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"content-disposition": "^3.0.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
@@ -392,7 +391,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"
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@types/d3-format": "^3.0.4",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^4.0.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
@@ -96,7 +97,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@types/d3-format": "^3.0.4",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-time": "^3.0.4",
|
||||
|
||||
+14
-4
@@ -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) {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
export { default as NumberFormats } from './NumberFormats';
|
||||
export { default as NumberFormatter, PREVIEW_VALUE } from './NumberFormatter';
|
||||
export { formatSpecifier } from 'd3-format';
|
||||
export type { FormatLocaleDefinition } from 'd3-format';
|
||||
export { DEFAULT_D3_FORMAT } from './D3FormatConfig';
|
||||
|
||||
export {
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
QueryFormData,
|
||||
} from '../query';
|
||||
import type { JsonResponse } from '../connection';
|
||||
import type { MenuItem } from '../components/Menu';
|
||||
|
||||
/**
|
||||
* A function which returns text (or marked-up text)
|
||||
@@ -164,6 +165,13 @@ export interface SliceHeaderExtension {
|
||||
dashboardId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for extensions to the Slice Header more-options menu
|
||||
*/
|
||||
export interface SliceHeaderMenuExtension extends SliceHeaderExtension {
|
||||
sliceName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for extensions to Embed Modal
|
||||
*/
|
||||
@@ -262,6 +270,9 @@ export type Extensions = Partial<{
|
||||
'sqleditor.extension.form': ComponentType<SQLFormExtensionProps>;
|
||||
'sqleditor.extension.resultTable': ComponentType<SQLResultTableExtensionProps>;
|
||||
'dashboard.slice.header': ComponentType<SliceHeaderExtension>;
|
||||
'dashboard.slice.header.menu': (
|
||||
context: SliceHeaderMenuExtension,
|
||||
) => MenuItem[];
|
||||
'sqleditor.extension.customAutocomplete': (
|
||||
args: CustomAutoCompleteArgs,
|
||||
) => CustomAutocomplete[] | undefined;
|
||||
|
||||
+25
@@ -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>],
|
||||
|
||||
+2
@@ -133,6 +133,8 @@ const defaultFormData: EchartsTimeseriesFormData & {
|
||||
metrics: [],
|
||||
minorSplitLine: false,
|
||||
minorTicks: false,
|
||||
gridlines: true,
|
||||
axisTicks: true,
|
||||
opacity: 1,
|
||||
orderDesc: false,
|
||||
rowLimit: 0,
|
||||
|
||||
+4
@@ -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>],
|
||||
|
||||
+4
@@ -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>],
|
||||
[
|
||||
|
||||
+4
@@ -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'),
|
||||
|
||||
+4
@@ -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: {
|
||||
|
||||
+55
@@ -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);
|
||||
});
|
||||
|
||||
+74
-1
@@ -36,6 +36,7 @@ import {
|
||||
SupersetClient,
|
||||
getClientErrorObject,
|
||||
getExtensionsRegistry,
|
||||
formatSpecifier,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -827,6 +828,78 @@ function EditorsSelector({
|
||||
const ResultTable =
|
||||
extensionsRegistry.get('sqleditor.extension.resultTable') ?? FilterableTable;
|
||||
|
||||
// D3's '%' and 'p' types both multiply by 100; parsed via d3-format's own
|
||||
// grammar so garbage like "foo%" is rejected rather than matched by suffix.
|
||||
// The stored value is validated untrimmed because getNumberFormatter formats
|
||||
// it untrimmed at render time; trimming here could flag a format (e.g. one
|
||||
// with trailing whitespace) as valid when the renderer's own parse rejects it.
|
||||
export const isPercentD3Format = (d3format?: string): boolean => {
|
||||
if (!d3format) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { type } = formatSpecifier(d3format);
|
||||
return type === '%' || type === 'p';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Matches the outermost COUNT(...) call's parens by depth, so a ratio like
|
||||
// `COUNT(*) / COUNT(*)` isn't misclassified but a nested call like
|
||||
// `COUNT(DISTINCT COALESCE(a, b))` is still recognized. Parens inside a
|
||||
// quoted string literal (single- or double-quoted, with a doubled quote as
|
||||
// an escaped quote) are ignored so they don't desync the depth count.
|
||||
export const isCountExpression = (expression?: string): boolean => {
|
||||
const trimmed = expression?.trim();
|
||||
if (!trimmed || !/^count\s*\(/i.test(trimmed) || !trimmed.endsWith(')')) {
|
||||
return false;
|
||||
}
|
||||
let depth = 0;
|
||||
let stringDelimiter: string | null = null;
|
||||
for (let i = trimmed.indexOf('('); i < trimmed.length; i += 1) {
|
||||
const char = trimmed[i];
|
||||
if (stringDelimiter) {
|
||||
if (char === stringDelimiter && trimmed[i + 1] === stringDelimiter) {
|
||||
i += 1;
|
||||
} else if (char === stringDelimiter) {
|
||||
stringDelimiter = null;
|
||||
}
|
||||
} else if (char === "'" || char === '"') {
|
||||
stringDelimiter = char;
|
||||
} else if (char === '(') {
|
||||
depth += 1;
|
||||
} else if (char === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return i === trimmed.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function renderMetricFormatWarning(item: Record<string, any>): ReactNode {
|
||||
if (
|
||||
!isCountExpression(item.expression) ||
|
||||
!isPercentD3Format(item.d3format)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
css={themeParam => ({ marginBottom: themeParam.sizeUnit * 4 })}
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t(
|
||||
'This metric is a count, but its D3 format is a percentage. ' +
|
||||
'Percent formats multiply the value by 100, which will make a ' +
|
||||
'raw count render as a misleadingly large number.',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Redux connector types
|
||||
interface QueryPayload {
|
||||
client_id?: string;
|
||||
@@ -2170,7 +2243,7 @@ function DatasourceEditor({
|
||||
}}
|
||||
expandFieldset={
|
||||
<FormContainer>
|
||||
<Fieldset compact>
|
||||
<Fieldset compact renderWarning={renderMetricFormatWarning}>
|
||||
<Field
|
||||
fieldKey="expression"
|
||||
label={t('SQL expression')}
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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 fetchMock from 'fetch-mock';
|
||||
import {
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { isCountExpression, isPercentD3Format } from '../DatasourceEditor';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
setupDatasourceEditorMocks,
|
||||
cleanupAsyncOperations,
|
||||
fastRender,
|
||||
dismissDatasourceWarning,
|
||||
DatasourceEditorProps,
|
||||
} from './DatasourceEditor.test.utils';
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
|
||||
setupDatasourceEditorMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupAsyncOperations();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
const WARNING_TEXT = /D3 format is a percentage/i;
|
||||
|
||||
// Selecting the expand toggle by its position in the list is brittle: any
|
||||
// change to the fixture or the table's sort order would expand a different
|
||||
// row and negative assertions would keep passing against the wrong metric.
|
||||
// Look up the toggle via the row that actually contains the metric name.
|
||||
const expandMetricRow = async (metricName: string) => {
|
||||
const nameCell = await screen.findByText(metricName);
|
||||
const row = nameCell.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error(`Could not find a table row for metric "${metricName}"`);
|
||||
}
|
||||
await userEvent.click(within(row).getByLabelText(/expand row/i));
|
||||
};
|
||||
|
||||
// A fixed-time sleep can't prove the debounced value actually committed, so
|
||||
// negative assertions would pass vacuously if the commit landed late on a
|
||||
// loaded runner. Instead, wait for the real signal of a commit: the metric's
|
||||
// d3format reaching the top-level onChange the editor calls after every
|
||||
// datasource state update.
|
||||
const waitForD3FormatCommit = (
|
||||
onChange: DatasourceEditorProps['onChange'],
|
||||
metricName: string,
|
||||
d3format: string,
|
||||
) =>
|
||||
waitFor(() => {
|
||||
const [datasource] = onChange.mock.calls.at(-1) ?? [];
|
||||
const metric = datasource?.metrics?.find(
|
||||
(m: { metric_name?: string }) => m.metric_name === metricName,
|
||||
);
|
||||
expect(metric?.d3format).toBe(d3format);
|
||||
});
|
||||
|
||||
test('isCountExpression matches a COUNT(...) call, including nested calls', () => {
|
||||
expect(isCountExpression('COUNT(*)')).toBe(true);
|
||||
expect(isCountExpression('count( * )')).toBe(true);
|
||||
expect(isCountExpression('COUNT (*)')).toBe(true);
|
||||
expect(isCountExpression('COUNT(DISTINCT name)')).toBe(true);
|
||||
expect(isCountExpression('COUNT(DISTINCT COALESCE(a, b))')).toBe(true);
|
||||
expect(isCountExpression('COUNT(*) / COUNT(*)')).toBe(false);
|
||||
expect(isCountExpression('COUNT(*) * 100')).toBe(false);
|
||||
expect(isCountExpression('SUM(num)')).toBe(false);
|
||||
expect(isCountExpression(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('isCountExpression ignores parens inside string literals', () => {
|
||||
expect(isCountExpression("COUNT(CASE WHEN x = '(' THEN 1 END)")).toBe(true);
|
||||
expect(isCountExpression("COUNT(CASE WHEN x = ')' THEN 1 END)")).toBe(true);
|
||||
expect(isCountExpression("COUNT(CASE WHEN x = '''(' THEN 1 END)")).toBe(true);
|
||||
});
|
||||
|
||||
test('isCountExpression ignores parens inside double-quoted identifiers', () => {
|
||||
expect(isCountExpression('COUNT("x\'")')).toBe(true);
|
||||
expect(isCountExpression('COUNT("y\'")')).toBe(true);
|
||||
expect(isCountExpression('COUNT(CASE WHEN "a""b" = 1 THEN 1 END)')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isPercentD3Format accepts only a valid D3 percent/p spec', () => {
|
||||
expect(isPercentD3Format('.0%')).toBe(true);
|
||||
expect(isPercentD3Format(',.2%')).toBe(true);
|
||||
expect(isPercentD3Format('.1p')).toBe(true);
|
||||
expect(isPercentD3Format('foo%')).toBe(false);
|
||||
expect(isPercentD3Format('.0%garbage%')).toBe(false);
|
||||
expect(isPercentD3Format(',.0f')).toBe(false);
|
||||
expect(isPercentD3Format(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
// getNumberFormatter formats the stored value untrimmed at render time, so
|
||||
// this must agree rather than accept a format the renderer then rejects.
|
||||
// Trailing whitespace is invalid D3 format syntax and must not be trimmed
|
||||
// away before validating.
|
||||
test('isPercentD3Format does not trim, matching untrimmed render-time parsing', () => {
|
||||
expect(isPercentD3Format('.0% ')).toBe(false);
|
||||
});
|
||||
|
||||
// A '%' format is valid syntax, so it never hits the "Invalid format" fallback.
|
||||
test('warns when a percent D3 format is set on a COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
await expandMetricRow('count');
|
||||
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
expect(await screen.findByText(WARNING_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a non-percent format on a COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
await expandMetricRow('count');
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), ',.0f');
|
||||
|
||||
await waitForD3FormatCommit(testProps.onChange, 'count', ',.0f');
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a percent format on a non-COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
await expandMetricRow('sum__num');
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
await waitForD3FormatCommit(testProps.onChange, 'sum__num', '.0%');
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a ratio built from COUNT, e.g. COUNT(*) / COUNT(*)', async () => {
|
||||
const baseProps = createProps();
|
||||
const testProps = {
|
||||
...baseProps,
|
||||
datasource: {
|
||||
...baseProps.datasource,
|
||||
metrics: [
|
||||
...baseProps.datasource.metrics,
|
||||
{
|
||||
id: 99,
|
||||
uuid: 'metric-99-uuid',
|
||||
expression: 'COUNT(*) / COUNT(*)',
|
||||
verbose_name: 'ratio',
|
||||
metric_name: 'ratio',
|
||||
metric_type: 'count',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
await expandMetricRow('ratio');
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
await waitForD3FormatCommit(testProps.onChange, 'ratio', '.0%');
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a garbage format string that merely ends in %', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
await expandMetricRow('count');
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), 'foo%');
|
||||
|
||||
await waitForD3FormatCommit(testProps.onChange, 'count', 'foo%');
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -28,6 +28,7 @@ export interface FieldsetProps {
|
||||
item?: Record<string, any>;
|
||||
title?: ReactNode;
|
||||
compact?: boolean;
|
||||
renderWarning?: (item: Record<string, any>) => ReactNode;
|
||||
}
|
||||
|
||||
type fieldKeyType = string | number;
|
||||
@@ -38,6 +39,7 @@ export default function Fieldset({
|
||||
item = {},
|
||||
title = null,
|
||||
compact = false,
|
||||
renderWarning,
|
||||
}: FieldsetProps) {
|
||||
// Controls report their edits asynchronously - TextControl debounces by
|
||||
// FAST_DEBOUNCE - so the callback that eventually fires was built during an
|
||||
@@ -78,6 +80,7 @@ export default function Fieldset({
|
||||
</Typography.Title>
|
||||
)}
|
||||
|
||||
{renderWarning?.(item)}
|
||||
{recurseReactClone(children, Field, propExtender)}
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
+56
-1
@@ -23,7 +23,7 @@ import {
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import { FeatureFlag, VizType, getExtensionsRegistry } from '@superset-ui/core';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
@@ -165,6 +165,9 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(document, 'fullscreenElement');
|
||||
// TypedRegistry has no remove(); reset to a no-op so a registered slot does
|
||||
// not leak into other tests (the empty array is guarded, so nothing injects).
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
|
||||
});
|
||||
|
||||
test('Should render', () => {
|
||||
@@ -173,6 +176,58 @@ test('Should render', () => {
|
||||
expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Injects dashboard.slice.header.menu items at the top of the menu', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => [
|
||||
{ key: 'custom-ext', label: 'Custom Menu Extension' },
|
||||
]);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
const injected = screen.getByText('Custom Menu Extension');
|
||||
expect(injected).toBeInTheDocument();
|
||||
// Sits above the built-in entries.
|
||||
const forceRefresh = screen.getByText('Force refresh');
|
||||
expect(
|
||||
injected.compareDocumentPosition(forceRefresh) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Injects nothing when dashboard.slice.header.menu returns no items', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
expect(screen.queryByText('Custom Menu Extension')).not.toBeInTheDocument();
|
||||
// The menu still renders its built-in entries unchanged (no dangling divider
|
||||
// is added since the empty array is guarded).
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Menu survives a dashboard.slice.header.menu extension that throws', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
// The throw is isolated: the built-in menu still renders.
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Injects nothing when the extension returns a non-array', () => {
|
||||
getExtensionsRegistry().set(
|
||||
'dashboard.slice.header.menu',
|
||||
// JS registrations bypass the MenuItem[] type; a bad return must not crash.
|
||||
(() => undefined) as never,
|
||||
);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should render default props', () => {
|
||||
const props = createProps();
|
||||
|
||||
|
||||
@@ -34,11 +34,13 @@ import {
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
getChartMetadataRegistry,
|
||||
getExtensionsRegistry,
|
||||
VizType,
|
||||
BinaryQueryObjectFilterClause,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme, styled } from '@apache-superset/core/theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
@@ -165,6 +167,8 @@ const queueChartResize = () => {
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
const SliceHeaderControls = (
|
||||
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
|
||||
) => {
|
||||
@@ -514,6 +518,26 @@ const SliceHeaderControls = (
|
||||
},
|
||||
];
|
||||
|
||||
const sliceHeaderMenuExtension = extensionsRegistry.get(
|
||||
'dashboard.slice.header.menu',
|
||||
);
|
||||
if (sliceHeaderMenuExtension) {
|
||||
// Isolate the extension: a bad registration (throwing, or returning a
|
||||
// non-array) must not take down the whole dashboard render.
|
||||
try {
|
||||
const extensionItems = sliceHeaderMenuExtension({
|
||||
sliceId: slice.slice_id,
|
||||
sliceName: slice.slice_name,
|
||||
dashboardId,
|
||||
});
|
||||
if (Array.isArray(extensionItems) && extensionItems.length) {
|
||||
newMenuItems.unshift(...extensionItems, { type: 'divider' });
|
||||
}
|
||||
} catch (error) {
|
||||
logging.error('dashboard.slice.header.menu extension failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (slice.description) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ToggleChartDescription,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,8 +32,8 @@ import {
|
||||
setCurrencyLocale,
|
||||
createLengthFormatter,
|
||||
createThroughputFormatter,
|
||||
FormatLocaleDefinition,
|
||||
} from '@superset-ui/core';
|
||||
import { FormatLocaleDefinition } from 'd3-format';
|
||||
import { TimeLocaleDefinition } from 'd3-time-format';
|
||||
|
||||
export default function setupFormatters(
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FormatLocaleDefinition } from 'd3-format';
|
||||
import { TimeLocaleDefinition } from 'd3-time-format';
|
||||
import { isPlainObject } from 'lodash-es';
|
||||
import { Languages } from 'src/features/home/LanguagePicker';
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
import type {
|
||||
ColorSchemeConfig,
|
||||
FeatureFlagMap,
|
||||
FormatLocaleDefinition,
|
||||
JsonObject,
|
||||
SequentialSchemeConfig,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../superset-frontend/.npmrc
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1646,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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**"
|
||||
|
||||
Reference in New Issue
Block a user