mirror of
https://github.com/apache/superset.git
synced 2026-08-28 02:51:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4fac82c39 | ||
|
|
3f10d8b1cc | ||
|
|
15b98aa6da | ||
|
|
7f1b41444c | ||
|
|
8a32324184 | ||
|
|
4623d9219a | ||
|
|
dcac129b51 | ||
|
|
7196ce8a0c | ||
|
|
e7756aeef9 | ||
|
|
af859717a7 | ||
|
|
8fa48d73f6 | ||
|
|
90a3d93002 | ||
|
|
5ffb72151e | ||
|
|
c3ed8b312d | ||
|
|
9f505eb0cb | ||
|
|
f2610e9dca | ||
|
|
09bd8c98de | ||
|
|
5812c0eab5 | ||
|
|
5f3e34908c | ||
|
|
9781254363 | ||
|
|
65893113fe | ||
|
|
04017f3956 | ||
|
|
2b6044b624 | ||
|
|
aee98b527d | ||
|
|
edda9d1bbb | ||
|
|
03eac279e5 | ||
|
|
fd7095df9a | ||
|
|
d422f5b4b6 |
@@ -3,9 +3,7 @@ codecov:
|
||||
after_n_builds: 4
|
||||
ignore:
|
||||
- "superset/migrations/versions/*.py"
|
||||
- "superset-frontend/packages/superset-ui-demo/**/*"
|
||||
- "**/*.stories.tsx"
|
||||
- "**/*.stories.jsx"
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Copy image to GHCR
|
||||
env:
|
||||
|
||||
@@ -75,13 +75,18 @@ jobs:
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
run: |
|
||||
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50 --junit-xml=test-results/junit-unit.xml
|
||||
# COVERAGE_FILE keeps these scoped gates off the default .coverage that
|
||||
# the step above wrote. pytest-cov starts a fresh data file per run, so
|
||||
# without it the last gate replaces the full-suite data and the report
|
||||
# uploaded below contains only that gate's subtree -- every other file
|
||||
# then reads as uncovered, and patch coverage on an unrelated PR is 0%.
|
||||
- name: Python 100% coverage unit tests
|
||||
env:
|
||||
SUPERSET_TESTENV: true
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
run: |
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-sql-coverage.xml
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-semantic-layers-coverage.xml
|
||||
COVERAGE_FILE="${RUNNER_TEMP}/.coverage.sql" pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-sql-coverage.xml
|
||||
COVERAGE_FILE="${RUNNER_TEMP}/.coverage.semantic_layers" pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-semantic-layers-coverage.xml
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -11,6 +11,11 @@ When soft-delete is enabled, deleting a chart, dashboard, or dataset archives it
|
||||
instead of removing it permanently. The **Recently Archived** view lets owners
|
||||
and admins find archived objects and restore them.
|
||||
|
||||
A chart used by an alert or report cannot be archived while that dependency
|
||||
exists. In the chart list view, the archive confirmation lists the alerts and
|
||||
reports that use the chart; a blocked attempt names them and asks you to
|
||||
detach or delete them first.
|
||||
|
||||
:::note
|
||||
|
||||
This view is gated by the `SOFT_DELETE` feature flag. When the flag is off the
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"js-yaml": "^5.2.3",
|
||||
"js-yaml": "^5.3.0",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -67,7 +67,7 @@ const communityLinks = [
|
||||
'Join our monthly virtual meetups and register for any upcoming events on Meetup',
|
||||
},
|
||||
{
|
||||
url: 'https://github.com/apache/superset/blob/master/RESOURCES/INTHEWILD.md',
|
||||
url: 'https://superset.apache.org/inTheWild/',
|
||||
title: 'Organizations',
|
||||
description:
|
||||
'A list of some of the organizations using Superset in production.',
|
||||
|
||||
+4
-4
@@ -10291,10 +10291,10 @@ js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, j
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
js-yaml@^5.2.3:
|
||||
version "5.2.3"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.2.3.tgz#0942ae8f507e22eb0e54624871789cd477106e54"
|
||||
integrity sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==
|
||||
js-yaml@^5.3.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.3.0.tgz#526430a6da31065127528ae695ce168cfc5f91f0"
|
||||
integrity sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
|
||||
Generated
+23
-26
@@ -86,7 +86,7 @@
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
@@ -100,7 +100,7 @@
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"immer": "^11.1.16",
|
||||
"immer": "^11.1.17",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
@@ -220,7 +220,7 @@
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
@@ -17190,9 +17190,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz",
|
||||
"integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==",
|
||||
"version": "10.0.5",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
|
||||
"integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -18541,9 +18541,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"version": "1.11.22",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.22.tgz",
|
||||
"integrity": "sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
@@ -23907,9 +23907,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.16",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz",
|
||||
"integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==",
|
||||
"version": "11.1.17",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.17.tgz",
|
||||
"integrity": "sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -34873,12 +34873,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.2.tgz",
|
||||
"integrity": "sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==",
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.3.tgz",
|
||||
"integrity": "sha512-GnSKpCohFi2nQmJCWwP8O8wub7zexlePvpsejvQr35vS5RTouS1+utTNOmyc540yw5vyOXnSL1rBWsCQDmkyUA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-google-recaptcha": {
|
||||
@@ -42962,7 +42968,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
@@ -42974,7 +42980,7 @@
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-error-boundary": "^6.1.3",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
@@ -43073,15 +43079,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
@@ -177,7 +177,7 @@
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"immer": "^11.1.16",
|
||||
"immer": "^11.1.17",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
@@ -297,7 +297,7 @@
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.5",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
|
||||
+15
-1
@@ -51,6 +51,20 @@ export interface MetricOptionProps {
|
||||
shouldShowTooltip?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `url` is an arbitrary caller-supplied string rendered as an href. Only
|
||||
* http(s) and relative URLs become links; other schemes degrade to plain
|
||||
* text.
|
||||
*/
|
||||
function isSafeHref(url: string): boolean {
|
||||
try {
|
||||
const { protocol } = new URL(url, window.location.origin);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function MetricOption({
|
||||
metric,
|
||||
labelRef,
|
||||
@@ -70,7 +84,7 @@ export function MetricOption({
|
||||
`}
|
||||
ref={labelRef}
|
||||
>
|
||||
{url ? (
|
||||
{url && isSafeHref(url) ? (
|
||||
<Typography.Link
|
||||
href={url}
|
||||
target={openInNewWindow ? '_blank' : ''}
|
||||
|
||||
+23
@@ -112,3 +112,26 @@ test('shows a Tooltip for the verbose metric name', () => {
|
||||
const { getByTestId } = setup();
|
||||
expect(getByTestId('mock-tooltip')).toBeInTheDocument();
|
||||
});
|
||||
test('does not render javascript: URLs as links', () => {
|
||||
// Regression test: the url prop can be creator-authored and must
|
||||
// never become a script-bearing href for other viewers.
|
||||
const { queryByRole, getByText } = setup({
|
||||
url: 'javascript:alert(document.domain)', // eslint-disable-line no-script-url
|
||||
});
|
||||
expect(queryByRole('link')).not.toBeInTheDocument();
|
||||
expect(getByText(defaultProps.metric.verbose_name)).toBeInTheDocument();
|
||||
});
|
||||
test('does not render data: URLs as links', () => {
|
||||
const { queryByRole } = setup({
|
||||
url: 'data:text/html,<script>alert(1)</script>',
|
||||
});
|
||||
expect(queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
test('renders relative URLs as links', () => {
|
||||
const { getByRole } = setup({
|
||||
url: '/superset/dashboard/1/',
|
||||
});
|
||||
expect(
|
||||
getByRole('link', { name: defaultProps.metric.verbose_name }),
|
||||
).toHaveAttribute('href', '/superset/dashboard/1/');
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dayjs": "^1.11.22",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
@@ -79,7 +79,7 @@
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.7.1",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-error-boundary": "^6.1.3",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
|
||||
@@ -49,5 +49,6 @@ export { default as finestTemporalGrainFormatter } from './formatters/finestTemp
|
||||
|
||||
export { default as normalizeTimestamp } from './utils/normalizeTimestamp';
|
||||
export { default as denormalizeTimestamp } from './utils/denormalizeTimestamp';
|
||||
export { default as createTimeRangeFromGranularity } from './utils/createTimeRangeFromGranularity';
|
||||
|
||||
export * from './types';
|
||||
|
||||
@@ -123,6 +123,25 @@ describe('isProbablyHTML', () => {
|
||||
expect(isProbablyHTML('<canvas></canvas>')).toBe(true);
|
||||
expect(isProbablyHTML('<iframe src="page.html"></iframe>')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for script-capable and foreign-content tags', () => {
|
||||
expect(isProbablyHTML('<svg onload="alert(1)"></svg>')).toBe(true);
|
||||
expect(isProbablyHTML('<math><mi>x</mi></math>')).toBe(true);
|
||||
expect(
|
||||
isProbablyHTML('<details open ontoggle="alert(1)">x</details>'),
|
||||
).toBe(true);
|
||||
expect(isProbablyHTML('<summary>x</summary>')).toBe(true);
|
||||
expect(isProbablyHTML('<object data="x"></object>')).toBe(true);
|
||||
expect(isProbablyHTML('<embed src="x">')).toBe(true);
|
||||
expect(isProbablyHTML('<marquee>x</marquee>')).toBe(true);
|
||||
expect(isProbablyHTML('<template>x</template>')).toBe(true);
|
||||
expect(isProbablyHTML('<dialog open>x</dialog>')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for elements that parse into document.head', () => {
|
||||
expect(isProbablyHTML('<style>body { display: none; }</style>')).toBe(true);
|
||||
expect(isProbablyHTML('<title>injected</title>')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeHtmlIfNeeded', () => {
|
||||
@@ -137,6 +156,24 @@ describe('sanitizeHtmlIfNeeded', () => {
|
||||
const sanitizedString = sanitizeHtmlIfNeeded(plainText);
|
||||
expect(sanitizedString).toEqual(plainText);
|
||||
});
|
||||
|
||||
test('should sanitize svg/details/style payloads instead of passing them through', () => {
|
||||
const svgPayload = '<svg onload="alert(document.cookie)"></svg>';
|
||||
const sanitizedSvg = sanitizeHtmlIfNeeded(svgPayload);
|
||||
expect(sanitizedSvg).not.toContain('<svg');
|
||||
expect(sanitizedSvg).not.toContain('onload');
|
||||
|
||||
// `details` (with its `open` attribute) is in js-xss's default
|
||||
// whitelist, so the tag itself survives sanitization; the fix is that
|
||||
// the payload is now routed through FilterXSS at all, which strips the
|
||||
// non-whitelisted `ontoggle` handler instead of returning it verbatim.
|
||||
const detailsPayload = '<details open ontoggle="alert(1)">x</details>';
|
||||
const sanitizedDetails = sanitizeHtmlIfNeeded(detailsPayload);
|
||||
expect(sanitizedDetails).toEqual('<details open>x</details>');
|
||||
|
||||
const stylePayload = '<style>body { display: none; }</style>';
|
||||
expect(sanitizeHtmlIfNeeded(stylePayload)).not.toContain('<style');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeHtmlSpan', () => {
|
||||
|
||||
@@ -154,6 +154,20 @@ const KNOWN_HTML_TAGS = new Set([
|
||||
'html',
|
||||
'head',
|
||||
'body',
|
||||
// Script-capable elements and foreign-content roots (SVG/MathML). These
|
||||
// must be classified as HTML so that downstream sanitization is applied;
|
||||
// omitting them makes the heuristic fail open — payloads such as
|
||||
// `<svg onload=...>` or `<details open ontoggle=...>` would be classified
|
||||
// "not HTML" and returned verbatim by sanitizeHtmlIfNeeded.
|
||||
'svg',
|
||||
'math',
|
||||
'details',
|
||||
'summary',
|
||||
'object',
|
||||
'embed',
|
||||
'marquee',
|
||||
'template',
|
||||
'dialog',
|
||||
]);
|
||||
|
||||
const HTML_TAG_PATTERN = new RegExp(
|
||||
@@ -183,10 +197,15 @@ export function isProbablyHTML(text: string) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(cleanedStr, 'text/html');
|
||||
|
||||
// Check if parsing created actual HTML elements (not just text nodes)
|
||||
const elements = Array.from(doc.body.childNodes).filter(
|
||||
node => node.nodeType === 1,
|
||||
) as Element[];
|
||||
// Check if parsing created actual HTML elements (not just text nodes).
|
||||
// Some elements (e.g. <style>, <title>, <meta>, <link>) parse into
|
||||
// document.head rather than document.body, so both must be inspected —
|
||||
// otherwise a bare <style> payload is classified "not HTML" and skips
|
||||
// sanitization.
|
||||
const elements = [
|
||||
...Array.from(doc.head.childNodes),
|
||||
...Array.from(doc.body.childNodes),
|
||||
].filter(node => node.nodeType === 1) as Element[];
|
||||
|
||||
// If no elements were created, it's not HTML
|
||||
if (elements.length === 0) {
|
||||
|
||||
@@ -40,10 +40,18 @@ import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiGet } from '../../helpers/api/requests';
|
||||
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { getAccessToken } from '../../helpers/api/embedded';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
async function authorizeApi(page: Page): Promise<void> {
|
||||
const accessToken = await getAccessToken(page);
|
||||
await page.context().setExtraHTTPHeaders({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Visible row text must never expose synthetic identifiers (layout node
|
||||
// ids like CHART-xyz / ROW-… or bare UUIDs) — the rendering layer maps
|
||||
// these to human names or kind-only phrasing.
|
||||
@@ -79,19 +87,35 @@ async function currentUserSubjectId(page: Page): Promise<number> {
|
||||
* this reads like its sibling specs, but fall back to whatever the instance
|
||||
* has rather than requiring a particular fixture to be loaded.
|
||||
*/
|
||||
async function anyDatasetId(page: Page): Promise<number> {
|
||||
async function anyDataset(page: Page): Promise<{
|
||||
id: number;
|
||||
columnName: string;
|
||||
}> {
|
||||
const named = await getDatasetByName(page, DATASET_NAME);
|
||||
if (named) {
|
||||
return named.id;
|
||||
let datasetId = named?.id;
|
||||
if (!datasetId) {
|
||||
const res = await apiGet(
|
||||
page,
|
||||
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
|
||||
);
|
||||
expect(res.ok(), 'dataset list request').toBeTruthy();
|
||||
const [first] = (await res.json()).result;
|
||||
expect(first, 'the instance has at least one dataset').toBeTruthy();
|
||||
datasetId = first.id;
|
||||
}
|
||||
const res = await apiGet(
|
||||
page,
|
||||
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
|
||||
);
|
||||
expect(res.ok(), 'dataset list request').toBeTruthy();
|
||||
const [first] = (await res.json()).result;
|
||||
expect(first, 'the instance has at least one dataset').toBeTruthy();
|
||||
return first.id;
|
||||
if (datasetId === undefined) {
|
||||
throw new Error('Unable to resolve a dataset id');
|
||||
}
|
||||
|
||||
const detailRes = await apiGet(page, `api/v1/dataset/${datasetId}`);
|
||||
expect(detailRes.ok(), 'dataset detail request').toBeTruthy();
|
||||
const { columns } = (await detailRes.json()).result;
|
||||
const [firstColumn] = columns;
|
||||
expect(firstColumn, 'the dataset has at least one column').toBeTruthy();
|
||||
return {
|
||||
id: datasetId,
|
||||
columnName: firstColumn.column_name,
|
||||
};
|
||||
}
|
||||
|
||||
/** Open the Explore "Additional actions → View version history" panel. */
|
||||
@@ -109,7 +133,8 @@ testWithAssets(
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const datasetId = await anyDatasetId(page);
|
||||
await authorizeApi(page);
|
||||
const { id: datasetId, columnName } = await anyDataset(page);
|
||||
|
||||
const baseName = `version_history_${Date.now()}`;
|
||||
const chartResp = await apiPostChart(page, {
|
||||
@@ -123,7 +148,7 @@ testWithAssets(
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: [],
|
||||
all_columns: [columnName],
|
||||
adhoc_filters: [],
|
||||
row_limit: 10,
|
||||
}),
|
||||
@@ -171,3 +196,79 @@ testWithAssets(
|
||||
).toBeFalsy();
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'minor edit of a non-canonical chart omits hydration noise',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
await authorizeApi(page);
|
||||
const { id: datasetId, columnName } = await anyDataset(page);
|
||||
const baseName = `version_history_normalization_${Date.now()}`;
|
||||
const chartResp = await apiPostChart(page, {
|
||||
slice_name: baseName,
|
||||
viz_type: 'table',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
// Deliberately omit visualization defaults. Explore hydration supplies
|
||||
// them, reproducing params imported before they were canonical.
|
||||
params: JSON.stringify({
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: [columnName],
|
||||
adhoc_filters: [],
|
||||
extra_form_data: {},
|
||||
dashboards: [],
|
||||
row_limit: 10,
|
||||
}),
|
||||
});
|
||||
expect(chartResp.ok(), 'chart creation').toBeTruthy();
|
||||
const chartBody = await chartResp.json();
|
||||
const chartId: number = chartBody.result?.id ?? chartBody.id;
|
||||
expect(chartId, 'chart creation should return an id').toBeTruthy();
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const adminSubjectId = await currentUserSubjectId(page);
|
||||
const editorResp = await apiPutChart(page, chartId, {
|
||||
editors: [adminSubjectId],
|
||||
});
|
||||
expect(editorResp.ok(), 'claim chart editorship').toBeTruthy();
|
||||
|
||||
await page.goto(`explore/?slice_id=${chartId}`);
|
||||
await page.getByRole('combobox', { name: 'Row limit' }).click();
|
||||
await page.getByRole('option', { name: '100', exact: true }).click();
|
||||
await page.locator('[data-test="query-save-button"]').click();
|
||||
await page.locator('[data-test="save-overwrite-radio"]').click();
|
||||
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
response =>
|
||||
response.request().method() === 'PUT' &&
|
||||
response.url().includes(`/api/v1/chart/${chartId}`),
|
||||
);
|
||||
await page.locator('[data-test="btn-modal-save"]').click();
|
||||
const saveResponse = await saveResponsePromise;
|
||||
expect(saveResponse.ok(), 'chart overwrite').toBeTruthy();
|
||||
|
||||
const requestPayload = saveResponse.request().postDataJSON();
|
||||
const savedParams = JSON.parse(requestPayload.params);
|
||||
expect(
|
||||
savedParams.matrixify_enable,
|
||||
'overwrite contains a default absent from the stored params',
|
||||
).toBe(false);
|
||||
|
||||
await openVersionHistory(page);
|
||||
const panel = page.locator('[aria-label="Version history"]');
|
||||
const newestGroup = panel
|
||||
.locator('[data-test="version-history-save-group"]')
|
||||
.first();
|
||||
await expect(newestGroup, 'shows the overwrite save group').toBeVisible();
|
||||
await newestGroup.getByRole('button').first().click();
|
||||
|
||||
const rows = newestGroup.locator(
|
||||
'[data-test="version-history-action-row"]',
|
||||
);
|
||||
await expect(rows, 'shows only the intentional edit').toHaveCount(1);
|
||||
await expect(rows.first()).toContainText(/row limit/i);
|
||||
},
|
||||
);
|
||||
|
||||
+11
-1
@@ -27,11 +27,21 @@ const StyledTotalCell = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
// `align` originates from the chart's stored column_config
|
||||
// (col.config.horizontalAlign), which can be set to an arbitrary string via
|
||||
// a direct chart-params API write. Emotion compiles interpolated strings as
|
||||
// CSS source, so the value must be clamped to a closed set of keywords
|
||||
// before it reaches the stylesheet — never interpolated raw.
|
||||
const ALLOWED_ALIGN_VALUES = new Set(['left', 'right', 'center']);
|
||||
|
||||
const safeAlign = (align?: string) =>
|
||||
align && ALLOWED_ALIGN_VALUES.has(align) ? align : 'left';
|
||||
|
||||
const CellContainer = styled.div<{ backgroundColor?: string; align?: string }>`
|
||||
display: flex;
|
||||
background-color: ${({ backgroundColor }) =>
|
||||
backgroundColor || 'transparent'};
|
||||
justify-content: ${({ align }) => align || 'left'};
|
||||
justify-content: ${({ align }) => safeAlign(align)};
|
||||
`;
|
||||
|
||||
const ArrowContainer = styled.div<{ arrowColor?: string }>`
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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 '@testing-library/jest-dom';
|
||||
import { render } from '@superset-ui/core/spec';
|
||||
import { NumericCellRenderer } from '../src/renderers/NumericCellRenderer';
|
||||
|
||||
const renderCell = (horizontalAlign?: string) => {
|
||||
const params = {
|
||||
value: 42,
|
||||
valueFormatted: '42',
|
||||
node: { rowPinned: undefined, rowIndex: 0 },
|
||||
hasBasicColorFormatters: false,
|
||||
basicColorFormatters: [],
|
||||
col: {
|
||||
isNumeric: true,
|
||||
config: horizontalAlign ? { horizontalAlign } : {},
|
||||
},
|
||||
valueRange: undefined,
|
||||
alignPositiveNegative: false,
|
||||
colorPositiveNegative: false,
|
||||
} as unknown as Parameters<typeof NumericCellRenderer>[0];
|
||||
return render(<NumericCellRenderer {...params} />);
|
||||
};
|
||||
|
||||
const collectInjectedCss = () =>
|
||||
Array.from(document.querySelectorAll('style'))
|
||||
.map(style => style.textContent ?? '')
|
||||
.join('\n');
|
||||
|
||||
test('applies an allowed horizontalAlign value from column config', () => {
|
||||
const { container } = renderCell('center');
|
||||
expect(container.firstChild).toHaveStyle({ justifyContent: 'center' });
|
||||
});
|
||||
|
||||
test('does not compile a malicious horizontalAlign into the stylesheet', () => {
|
||||
const payload =
|
||||
'right;} & { position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:99999; background:#fff url(https://attacker.example/beacon) }';
|
||||
const { container } = renderCell(payload);
|
||||
const css = collectInjectedCss();
|
||||
expect(css).not.toContain('position:fixed');
|
||||
expect(css).not.toContain('attacker.example');
|
||||
expect(container.firstChild).toHaveStyle({ justifyContent: 'left' });
|
||||
});
|
||||
@@ -38,3 +38,11 @@ export const convertUTCTimestampToLocal = (utcTimestamp: number): number => {
|
||||
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
|
||||
return utcTimestamp + offsetMs;
|
||||
};
|
||||
|
||||
// Escapes HTML special characters before formatter output reaches an
|
||||
// innerHTML sink. Mirrors plugin-chart-country-map's escapeHtml.
|
||||
export const escapeHtml = (text: unknown): string => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import d3tip from 'd3-tip';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getContrastingColor } from '@superset-ui/core';
|
||||
import { CALENDAR_TOOLTIP_CLASS } from '../tooltip';
|
||||
import { escapeHtml } from '../utils';
|
||||
|
||||
var d3 = typeof require === 'function' ? require('d3') : window.d3;
|
||||
|
||||
@@ -22,14 +23,16 @@ var CalHeatMap = function () {
|
||||
'use strict';
|
||||
|
||||
var self = this;
|
||||
// d3-tip assigns the .html() return value to the tip node via
|
||||
// innerHTML, so formatter output is HTML-escaped first.
|
||||
self.tip = d3tip()
|
||||
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
|
||||
.direction('n')
|
||||
.offset([-5, 0])
|
||||
.html(
|
||||
d => `
|
||||
${self.options.timeFormatter(d.t)}: <strong>${self.options.valueFormatter(
|
||||
d.v,
|
||||
${escapeHtml(self.options.timeFormatter(d.t))}: <strong>${escapeHtml(
|
||||
self.options.valueFormatter(d.v),
|
||||
)}</strong>
|
||||
`,
|
||||
);
|
||||
@@ -37,7 +40,7 @@ var CalHeatMap = function () {
|
||||
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
|
||||
.direction('n')
|
||||
.offset([-5, 0])
|
||||
.html(d => self.options.valueFormatter(d));
|
||||
.html(d => escapeHtml(self.options.valueFormatter(d)));
|
||||
|
||||
this.allowedDataType = ['json', 'csv', 'tsv', 'txt'];
|
||||
|
||||
|
||||
@@ -25,8 +25,12 @@ type FunctionalDateFormat = (date: Date) => string;
|
||||
interface CalHeatMapInstance {
|
||||
options: {
|
||||
dateFormatter: DateFormatter | null;
|
||||
timeFormatter: (t: number) => string;
|
||||
valueFormatter: (v: number) => string;
|
||||
};
|
||||
formatDate(date: Date, format: string | FunctionalDateFormat): string;
|
||||
tip: { html(): (d: { t: number; v: number }) => string };
|
||||
legendTip: { html(): (d: number) => string };
|
||||
}
|
||||
|
||||
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
|
||||
@@ -59,3 +63,29 @@ test('CalHeatMap keeps the D3 formatter fallback', () => {
|
||||
|
||||
expect(calendar.formatDate(date, '%B')).toBe('January');
|
||||
});
|
||||
|
||||
test('cell tooltip HTML escapes creator-controlled formatter output', () => {
|
||||
// Regression test: the tip's .html() callback is assigned to the
|
||||
// tooltip node via innerHTML (d3-tip), so formatter output must be
|
||||
// escaped before it's returned.
|
||||
const calendar = new CalHeatMap();
|
||||
calendar.options.timeFormatter = () => '<img src=x onerror=alert(1)>';
|
||||
calendar.options.valueFormatter = () => '<svg onload=alert(2)>';
|
||||
|
||||
const html = calendar.tip.html()({ t: 0, v: 1 });
|
||||
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('<svg');
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain('<svg');
|
||||
});
|
||||
|
||||
test('legend tooltip HTML escapes creator-controlled formatter output', () => {
|
||||
const calendar = new CalHeatMap();
|
||||
calendar.options.valueFormatter = () => '<img src=x onerror=alert(1)>';
|
||||
|
||||
const html = calendar.legendTip.html()(1);
|
||||
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('<img');
|
||||
});
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getFormattedUTCTime, convertUTCTimestampToLocal } from '../src/utils';
|
||||
import {
|
||||
getFormattedUTCTime,
|
||||
convertUTCTimestampToLocal,
|
||||
escapeHtml,
|
||||
} from '../src/utils';
|
||||
|
||||
test('getFormattedUTCTime formats local timestamp for display as UTC date', () => {
|
||||
const utcTimestamp = 1420070400000; // 2015-01-01 00:00:00 UTC
|
||||
@@ -87,3 +91,22 @@ test('convertUTCTimestampToLocal and getFormattedUTCTime work together to displa
|
||||
const formattedTime = getFormattedUTCTime(localTimestamp, '%Y-%m-%d');
|
||||
expect(formattedTime).toContain('2024-01-01');
|
||||
});
|
||||
|
||||
test('escapeHtml neutralizes markup smuggled through a time format string', () => {
|
||||
// Regression test: d3-time-format passes non-% characters through
|
||||
// verbatim, so escaping must happen before the innerHTML sink.
|
||||
const formatted = getFormattedUTCTime(
|
||||
1704067200000,
|
||||
'%Y <img src=x onerror=alert(1)>',
|
||||
);
|
||||
const escaped = escapeHtml(formatted);
|
||||
|
||||
expect(formatted).toContain('<img');
|
||||
expect(escaped).not.toContain('<img');
|
||||
expect(escaped).toContain('<img');
|
||||
});
|
||||
|
||||
test('escapeHtml stringifies non-string formatter output safely', () => {
|
||||
expect(escapeHtml(1234)).toEqual('1234');
|
||||
expect(escapeHtml('a & b < c')).toEqual('a & b < c');
|
||||
});
|
||||
|
||||
@@ -33,6 +33,27 @@ import { WmsLayerConf, WfsLayerConf, LayerConf, XyzLayerConf } from '../types';
|
||||
import { isWfsLayerConf, isWmsLayerConf, isXyzLayerConf } from '../typeguards';
|
||||
import { isVersionBelow } from './serviceUtil';
|
||||
|
||||
/**
|
||||
* Escape HTML special characters in a layer attribution string.
|
||||
*
|
||||
* OpenLayers' Attribution control renders attribution strings via innerHTML,
|
||||
* and the attribution here comes from creator-supplied chart form data, so it
|
||||
* must be treated as untrusted text rather than markup to prevent stored XSS.
|
||||
*
|
||||
* @param attribution The attribution string from the layer configuration
|
||||
*
|
||||
* @returns The attribution with HTML special characters escaped
|
||||
*/
|
||||
export const escapeAttribution = (attribution?: string): string | undefined =>
|
||||
attribution === undefined
|
||||
? undefined
|
||||
: attribution
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
/**
|
||||
* Create a WMS layer.
|
||||
*
|
||||
@@ -49,7 +70,7 @@ export const createWmsLayer = (wmsLayerConf: WmsLayerConf) => {
|
||||
LAYERS: layersParam,
|
||||
VERSION: version,
|
||||
},
|
||||
attributions: attribution,
|
||||
attributions: escapeAttribution(attribution),
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -66,7 +87,7 @@ export const createXyzLayer = (xyzLayerConf: XyzLayerConf) => {
|
||||
return new TileLayer({
|
||||
source: new XyzSource({
|
||||
url,
|
||||
attributions: attribution,
|
||||
attributions: escapeAttribution(attribution),
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -90,7 +111,7 @@ export const createWfsLayer = async (wfsLayerConf: WfsLayerConf) => {
|
||||
|
||||
const wfsSource = new VectorSource({
|
||||
format: new GeoJSON(),
|
||||
attributions: attribution,
|
||||
attributions: escapeAttribution(attribution),
|
||||
url: extent => {
|
||||
const requestUrl = new URL(url);
|
||||
const params = requestUrl.searchParams;
|
||||
|
||||
@@ -17,20 +17,65 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { WfsLayerConf } from '../../src/types';
|
||||
import { WfsLayerConf, WmsLayerConf, XyzLayerConf } from '../../src/types';
|
||||
import {
|
||||
createLayer,
|
||||
createWfsLayer,
|
||||
createWmsLayer,
|
||||
createXyzLayer,
|
||||
escapeAttribution,
|
||||
} from '../../src/util/layerUtil';
|
||||
|
||||
describe('layerUtil', () => {
|
||||
describe('escapeAttribution', () => {
|
||||
test('escapes HTML markup in attribution strings', () => {
|
||||
expect(escapeAttribution('(c) OSM <img src=x onerror=alert(1)>')).toBe(
|
||||
'(c) OSM <img src=x onerror=alert(1)>',
|
||||
);
|
||||
expect(escapeAttribution('a & "b" \'c\'')).toBe(
|
||||
'a & "b" 'c'',
|
||||
);
|
||||
expect(escapeAttribution(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWmsLayer', () => {
|
||||
test('exists', () => {
|
||||
// function is trivial
|
||||
expect(createWmsLayer).toBeDefined();
|
||||
});
|
||||
|
||||
test('escapes HTML in the layer attribution', () => {
|
||||
const wmsLayerConf: WmsLayerConf = {
|
||||
title: 'wms',
|
||||
type: 'WMS',
|
||||
url: 'https://ows-demo.terrestris.de/geoserver/osm/wms',
|
||||
version: '1.3.0',
|
||||
layersParam: 'osm:osm-fuel',
|
||||
attribution: '(c) OSM <img src=x onerror=alert(1)>',
|
||||
};
|
||||
const layer = createWmsLayer(wmsLayerConf);
|
||||
const attributions = layer.getSource()?.getAttributions();
|
||||
expect(attributions?.(undefined as never)).toEqual([
|
||||
'(c) OSM <img src=x onerror=alert(1)>',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createXyzLayer', () => {
|
||||
test('escapes HTML in the layer attribution', () => {
|
||||
const xyzLayerConf: XyzLayerConf = {
|
||||
title: 'osm',
|
||||
type: 'XYZ',
|
||||
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '(c) OSM <img src=x onerror=alert(1)>',
|
||||
};
|
||||
const layer = createXyzLayer(xyzLayerConf);
|
||||
const attributions = layer.getSource()?.getAttributions();
|
||||
expect(attributions?.(undefined as never)).toEqual([
|
||||
'(c) OSM <img src=x onerror=alert(1)>',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWfsLayer', () => {
|
||||
|
||||
+3
-1
@@ -770,7 +770,9 @@ export default function transformProps(
|
||||
nameGap: xAxisTitleMarginPx,
|
||||
nameLocation: 'middle',
|
||||
axisLabel: {
|
||||
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
hideOverlap: showMaxLabel
|
||||
? false
|
||||
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
formatter: deduplicatedFormatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { NumberFormatter } from '@superset-ui/core';
|
||||
import { sanitizeHtml } from '../utils/series';
|
||||
|
||||
/*
|
||||
function for finding the max metric values among all series data for Radar Chart
|
||||
@@ -63,7 +64,7 @@ export const renderNormalizedTooltip = (
|
||||
const { color, name = '', value: values } = params;
|
||||
const seriesName = name || 'series0';
|
||||
|
||||
const colorDot = `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:5px;height:5px;background-color:${color}"></span>`;
|
||||
const colorDot = `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:5px;height:5px;background-color:${sanitizeHtml(color)}"></span>`;
|
||||
|
||||
// Get metric values with denormalization if needed
|
||||
const metricValues: TooltipMetricValue[] = metrics.map((metric, index) => {
|
||||
@@ -85,19 +86,26 @@ export const renderNormalizedTooltip = (
|
||||
};
|
||||
});
|
||||
|
||||
// Tooltip is rendered via innerHTML (ECharts default renderMode
|
||||
// 'html'), so seriesName/metric/value/color are HTML-escaped, matching
|
||||
// the treatment every other echarts tooltip path applies.
|
||||
const tooltipRows = metricValues
|
||||
.map(
|
||||
({ metric, value }) => `
|
||||
<div style="display:flex;">
|
||||
<div>${colorDot}${metric}:</div>
|
||||
<div style="font-weight:bold;margin-left:auto;">${value}</div>
|
||||
<div>${colorDot}${sanitizeHtml(metric)}:</div>
|
||||
<div style="font-weight:bold;margin-left:auto;">${sanitizeHtml(
|
||||
String(value),
|
||||
)}</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div style="font-weight:bold;margin-bottom:5px;">${seriesName}</div>
|
||||
<div style="font-weight:bold;margin-bottom:5px;">${sanitizeHtml(
|
||||
seriesName,
|
||||
)}</div>
|
||||
${tooltipRows}
|
||||
`;
|
||||
};
|
||||
|
||||
+684
-52
@@ -21,8 +21,14 @@ import {
|
||||
waitFor,
|
||||
cleanup,
|
||||
} from '../../../../spec/helpers/testing-library';
|
||||
import { AxisType, TimeGranularity } from '@superset-ui/core';
|
||||
import {
|
||||
AxisType,
|
||||
createTimeRangeFromGranularity,
|
||||
DTTM_ALIAS,
|
||||
TimeGranularity,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -93,12 +99,17 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
cleanup();
|
||||
mockEchart.mockReset();
|
||||
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
|
||||
originalResizeObserver;
|
||||
});
|
||||
|
||||
const advanceClickTimer = () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
};
|
||||
|
||||
const defaultFormData: EchartsTimeseriesFormData & {
|
||||
vizType: string;
|
||||
dateFormat: string;
|
||||
@@ -314,7 +325,8 @@ test('falls back to window resize listener when ResizeObserver is unavailable',
|
||||
});
|
||||
|
||||
// Test for issue #25334: Bar chart cross-filter without dimensions
|
||||
test('emits cross-filter on X-axis value when no dimensions and categorical X-axis', async () => {
|
||||
test('emits cross-filter on X-axis value when no dimensions and categorical X-axis', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
const propsWithCategoricalXAxis: TimeseriesChartTransformedProps = {
|
||||
@@ -348,13 +360,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
// Wait for the timer (TIMER_DURATION = 300ms)
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(setDataMaskMock).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
advanceClickTimer();
|
||||
|
||||
// Verify the cross-filter uses the X-axis column and value, not the metric
|
||||
const dataMaskCall = setDataMaskMock.mock.calls[0][0];
|
||||
@@ -368,7 +374,8 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
|
||||
}
|
||||
});
|
||||
|
||||
test('emits cross-filter on category value for horizontal bar clicks', async () => {
|
||||
test('emits cross-filter on category value for horizontal bar clicks', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
@@ -397,12 +404,7 @@ test('emits cross-filter on category value for horizontal bar clicks', async ()
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(setDataMaskMock).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
@@ -424,9 +426,7 @@ test('uses rendered categorical axis for query event handlers', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
|
||||
'xAxis.category',
|
||||
);
|
||||
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe('xAxis');
|
||||
|
||||
cleanup();
|
||||
mockEchart.mockReset();
|
||||
@@ -445,9 +445,7 @@ test('uses rendered categorical axis for query event handlers', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
|
||||
'yAxis.category',
|
||||
);
|
||||
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe('yAxis');
|
||||
});
|
||||
|
||||
test('emits cross-filter from horizontal categorical axis label clicks', () => {
|
||||
@@ -473,8 +471,9 @@ test('emits cross-filter from horizontal categorical axis label clicks', () => {
|
||||
getLatestEchartProps().queryEventHandlers?.[0].handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
targetType: 'axisLabel',
|
||||
value: 'Product A',
|
||||
} as ECElementEvent);
|
||||
} as unknown as ECElementEvent);
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
@@ -485,7 +484,8 @@ test('emits cross-filter from horizontal categorical axis label clicks', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
|
||||
test('does not emit duplicate cross-filter for generic axis label clicks', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
@@ -507,11 +507,28 @@ test('does not emit duplicate cross-filter for generic axis label clicks', async
|
||||
name: 'Product A',
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
jest.advanceTimersByTime(400);
|
||||
expect(setDataMaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
|
||||
test('keeps temporal range exclusive ends on whole-second boundaries', () => {
|
||||
const clickedTimestamp = new Date(Date.UTC(2021, 0, 15, 12, 34, 56, 789));
|
||||
|
||||
[TimeGranularity.DAY, TimeGranularity.MONTH, TimeGranularity.YEAR].forEach(
|
||||
grain => {
|
||||
const [, inclusiveEnd] = createTimeRangeFromGranularity(
|
||||
clickedTimestamp,
|
||||
grain,
|
||||
false,
|
||||
);
|
||||
const exclusiveEnd = new Date(inclusiveEnd.getTime() + 1);
|
||||
|
||||
expect(exclusiveEnd.getUTCMilliseconds()).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('emits TEMPORAL_RANGE cross-filter from time axis label click on day bucket', () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
const propsWithTimeXAxis: TimeseriesChartTransformedProps = {
|
||||
@@ -519,39 +536,610 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
|
||||
emitCrossFilters: true,
|
||||
setDataMask: setDataMaskMock,
|
||||
groupby: [], // No dimensions
|
||||
resolvedTimeGrain: TimeGranularity.DAY,
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.DAY,
|
||||
},
|
||||
xAxis: {
|
||||
label: '__timestamp',
|
||||
type: AxisType.Time, // Time-based X-axis (not categorical)
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
};
|
||||
|
||||
render(<EchartsTimeseries {...propsWithTimeXAxis} />);
|
||||
|
||||
const lastCall = mockEchart.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
const [props] = lastCall as [EchartsProps];
|
||||
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
|
||||
({ query }) => query === 'xAxis',
|
||||
)?.handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
targetType: 'axisLabel',
|
||||
value: '2021-01-01',
|
||||
} as unknown as ECElementEvent);
|
||||
|
||||
// Simulate a click event
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [1609459200000, 100], // Timestamp
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Wait a bit and verify setDataMask was NOT called
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
expect(setDataMaskMock).not.toHaveBeenCalled();
|
||||
}
|
||||
test('emits TEMPORAL_RANGE cross-filter from time axis label click on month bucket', () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
resolvedTimeGrain={TimeGranularity.MONTH}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
|
||||
({ query }) => query === 'xAxis',
|
||||
)?.handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
targetType: 'axisLabel',
|
||||
value: '2021-01-01',
|
||||
} as unknown as ECElementEvent);
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emits TEMPORAL_RANGE cross-filter from time axis label click on year bucket', () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
resolvedTimeGrain={TimeGranularity.YEAR}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.YEAR,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
|
||||
({ query }) => query === 'xAxis',
|
||||
)?.handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
targetType: 'axisLabel',
|
||||
value: '2021-01-01',
|
||||
} as unknown as ECElementEvent);
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2022-01-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emits upper-exclusive TEMPORAL_RANGE from time point click on month bucket', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
resolvedTimeGrain={TimeGranularity.MONTH}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emits TEMPORAL_RANGE from string-typed time point click value', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
resolvedTimeGrain={TimeGranularity.MONTH}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: ['2021-01-01T00:00:00Z', 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses resolved time grain for temporal point-click cross-filter', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
resolvedTimeGrain={TimeGranularity.MONTH}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.DAY,
|
||||
extraFormData: {
|
||||
time_grain_sqla: TimeGranularity.MONTH,
|
||||
},
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emits TEMPORAL_RANGE from horizontal time point click using timestamp, not metric', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
resolvedTimeGrain={TimeGranularity.MONTH}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [129, Date.UTC(2021, 0, 1)],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emits TEMPORAL_RANGE cross-filter from horizontal time axis label click', () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
resolvedTimeGrain={TimeGranularity.MONTH}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
|
||||
({ query }) => query === 'yAxis',
|
||||
)?.handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
targetType: 'axisLabel',
|
||||
value: '2021-01-01',
|
||||
} as unknown as ECElementEvent);
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('warns and skips temporal axis label cross-filter when label value cannot be parsed', () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
const warn = jest.spyOn(logging, 'warn').mockImplementation();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
|
||||
({ query }) => query === 'xAxis',
|
||||
)?.handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
targetType: 'axisLabel',
|
||||
value: 'not-a-date',
|
||||
} as unknown as ECElementEvent);
|
||||
|
||||
expect(setDataMaskMock).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Unable to parse time axis value for cross-filtering',
|
||||
'not-a-date',
|
||||
);
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('logs and skips temporal point-click cross-filter when string value cannot be parsed', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
const warn = jest.spyOn(logging, 'warn').mockImplementation();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
resolvedTimeGrain={TimeGranularity.MONTH}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: ['not-a-date', 100],
|
||||
name: 'not-a-date',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMaskMock).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Unable to parse time axis value for cross-filtering',
|
||||
'not-a-date',
|
||||
);
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('emits empty temporal X-axis data mask when filter grain is missing', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: undefined,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0]).toEqual({
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
filterState: {
|
||||
label: undefined,
|
||||
value: null,
|
||||
selectedValues: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('clears temporal X-axis cross-filter when clicking selected bucket again', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
const selectedRange = '2021-01-01T00:00:00 : 2021-02-01T00:00:00';
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={[]}
|
||||
selectedValues={{ 0: selectedRange }}
|
||||
resolvedTimeGrain={TimeGranularity.MONTH}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0]).toEqual({
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
filterState: {
|
||||
label: undefined,
|
||||
value: null,
|
||||
selectedValues: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not emit temporal X-axis label cross-filter when dimensions are set', () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={['country']}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
groupby: ['country'],
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
|
||||
({ query }) => query === 'xAxis',
|
||||
)?.handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
targetType: 'axisLabel',
|
||||
value: '2021-01-01',
|
||||
} as unknown as ECElementEvent);
|
||||
|
||||
expect(setDataMaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not emit temporal X-axis cross-filter when dimensions are set', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
groupby={['country']}
|
||||
labelMap={{
|
||||
Sales: ['US'],
|
||||
}}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
groupby: ['country'],
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'country',
|
||||
op: 'IN',
|
||||
val: ['US'],
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
setDataMaskMock.mock.calls[0][0].extraFormData.filters.some(
|
||||
(filter: { op: string }) => filter.op === 'TEMPORAL_RANGE',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Test for issue #41102: horizontal bar cross-filter must use the category
|
||||
// value, not the metric. For horizontal bars the data tuple is value-first
|
||||
// (e.g. [100, 'Product A']), so relying on data[0] emitted the metric value.
|
||||
test('emits cross-filter on the category value for a horizontal categorical bar', async () => {
|
||||
test('emits cross-filter on the category value for a horizontal categorical bar', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
const propsWithHorizontalXAxis: TimeseriesChartTransformedProps = {
|
||||
@@ -585,12 +1173,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(setDataMaskMock).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
advanceClickTimer();
|
||||
|
||||
// Must filter on the category ('Product A'), not the metric value (100)
|
||||
const dataMaskCall = setDataMaskMock.mock.calls[0][0];
|
||||
@@ -604,6 +1187,55 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
|
||||
}
|
||||
});
|
||||
|
||||
test('context menu cross-filter is available for a temporal bar point', async () => {
|
||||
const onContextMenuMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
onContextMenu={onContextMenuMock}
|
||||
groupby={[]}
|
||||
resolvedTimeGrain={TimeGranularity.MONTH}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.DAY,
|
||||
extraFormData: {
|
||||
time_grain_sqla: TimeGranularity.MONTH,
|
||||
},
|
||||
}}
|
||||
xAxis={{
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
|
||||
expect(contextMenuHandler).toBeDefined();
|
||||
await contextMenuHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onContextMenuMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const { crossFilter } = onContextMenuMock.mock.calls[0][2];
|
||||
expect(crossFilter.dataMask.extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Test for issue #41102: the context-menu ("Add cross-filter") path must also
|
||||
// use the category value, not the metric, for a horizontal categorical bar.
|
||||
test('context menu cross-filter uses the category value for a horizontal categorical bar', async () => {
|
||||
|
||||
+168
-37
@@ -21,14 +21,17 @@ import {
|
||||
DTTM_ALIAS,
|
||||
BinaryQueryObjectFilterClause,
|
||||
AxisType,
|
||||
type TimeGranularity,
|
||||
getTimeFormatter,
|
||||
getColumnLabel,
|
||||
getNumberFormatter,
|
||||
LegendState,
|
||||
ensureIsArray,
|
||||
createTimeRangeFromGranularity,
|
||||
} from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import type {
|
||||
ECElementEvent,
|
||||
ViewRootGroup,
|
||||
@@ -48,6 +51,29 @@ import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
|
||||
import { ExtraControls } from '../components/ExtraControls';
|
||||
|
||||
const TIMER_DURATION = 300;
|
||||
const getTimestampFromTimeAxisValue = (value: string | number) => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
const timestamp = Date.parse(value);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
logging.warn('Unable to parse time axis value for cross-filtering', value);
|
||||
}
|
||||
return Number.isNaN(timestamp) ? undefined : timestamp;
|
||||
};
|
||||
|
||||
// Day, month, and year ranges end at 23:59:59.999, so adding 1ms lands on a
|
||||
// whole-second next bucket boundary. The formatter intentionally emits seconds.
|
||||
const formatDateTime = (date: Date) =>
|
||||
`${[
|
||||
date.getUTCFullYear(),
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0'),
|
||||
String(date.getUTCDate()).padStart(2, '0'),
|
||||
].join('-')}T${[
|
||||
String(date.getUTCHours()).padStart(2, '0'),
|
||||
String(date.getUTCMinutes()).padStart(2, '0'),
|
||||
String(date.getUTCSeconds()).padStart(2, '0'),
|
||||
].join(':')}`;
|
||||
|
||||
// Percent-change draggable baseline handle geometry, in pixels.
|
||||
const BASELINE_HANDLE_WIDTH = 8;
|
||||
@@ -71,6 +97,7 @@ export default function EchartsTimeseries({
|
||||
onFocusedSeries,
|
||||
xValueFormatter,
|
||||
xAxis,
|
||||
resolvedTimeGrain,
|
||||
refs,
|
||||
emitCrossFilters,
|
||||
coltypeMapping,
|
||||
@@ -363,6 +390,65 @@ export default function EchartsTimeseries({
|
||||
[selectedValues, xAxis.label],
|
||||
);
|
||||
|
||||
const getTimeAxisCrossFilterDataMask = useCallback(
|
||||
(clickedTimestamp: number) => {
|
||||
const filterColumn =
|
||||
xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
|
||||
const grain = resolvedTimeGrain as TimeGranularity | undefined;
|
||||
|
||||
if (!filterColumn || !grain) {
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
filterState: {
|
||||
label: undefined,
|
||||
value: null,
|
||||
selectedValues: null,
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [start, inclusiveEnd] = createTimeRangeFromGranularity(
|
||||
new Date(clickedTimestamp),
|
||||
grain,
|
||||
false,
|
||||
);
|
||||
const exclusiveEnd = new Date(inclusiveEnd.getTime() + 1);
|
||||
const timeRange = `${formatDateTime(start)} : ${formatDateTime(exclusiveEnd)}`;
|
||||
const selected: string[] = Object.values(selectedValues);
|
||||
const isCurrentValueSelected = selected.includes(timeRange);
|
||||
const values = isCurrentValueSelected ? [] : [timeRange];
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters:
|
||||
values.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
col: filterColumn,
|
||||
op: 'TEMPORAL_RANGE' as const,
|
||||
val: timeRange,
|
||||
},
|
||||
],
|
||||
},
|
||||
filterState: {
|
||||
label: values.length ? values : undefined,
|
||||
value: values.length ? values : null,
|
||||
selectedValues: values.length ? values : null,
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected,
|
||||
};
|
||||
},
|
||||
[formData.granularitySqla, resolvedTimeGrain, selectedValues, xAxis.label],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!emitCrossFilters) {
|
||||
@@ -384,15 +470,26 @@ export default function EchartsTimeseries({
|
||||
[emitCrossFilters, setDataMask, getXAxisCrossFilterDataMask],
|
||||
);
|
||||
|
||||
const handleTimeAxisChange = useCallback(
|
||||
(clickedTimestamp: number) => {
|
||||
if (!emitCrossFilters) {
|
||||
return;
|
||||
}
|
||||
setDataMask(getTimeAxisCrossFilterDataMask(clickedTimestamp).dataMask);
|
||||
},
|
||||
[emitCrossFilters, setDataMask, getTimeAxisCrossFilterDataMask],
|
||||
);
|
||||
|
||||
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
|
||||
const canCrossFilterByXAxis =
|
||||
!hasDimensions && xAxis.type === AxisType.Category;
|
||||
const categoryAxisValueIndex =
|
||||
!hasDimensions &&
|
||||
(xAxis.type === AxisType.Category || xAxis.type === AxisType.Time);
|
||||
const xAxisValueIndex =
|
||||
formData.orientation === OrientationType.Horizontal ? 1 : 0;
|
||||
const getCategoryAxisValue = useCallback(
|
||||
const getXAxisValue = useCallback(
|
||||
(data: unknown, name: unknown) => {
|
||||
if (Array.isArray(data)) {
|
||||
const categoryAxisValue = data[categoryAxisValueIndex];
|
||||
const categoryAxisValue = data[xAxisValueIndex];
|
||||
if (
|
||||
typeof categoryAxisValue === 'string' ||
|
||||
typeof categoryAxisValue === 'number'
|
||||
@@ -405,7 +502,7 @@ export default function EchartsTimeseries({
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[categoryAxisValueIndex],
|
||||
[xAxisValueIndex],
|
||||
);
|
||||
|
||||
const eventHandlers: EventHandlers = {
|
||||
@@ -423,15 +520,28 @@ export default function EchartsTimeseries({
|
||||
// Cross-filter by dimension (original behavior)
|
||||
const { seriesName: name } = props;
|
||||
handleChange(name);
|
||||
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
|
||||
} else if (
|
||||
canCrossFilterByXAxis &&
|
||||
xAxis.type === AxisType.Category &&
|
||||
props.componentType === 'series'
|
||||
) {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334)
|
||||
const categoryAxisValue = getCategoryAxisValue(
|
||||
props.data,
|
||||
props.name,
|
||||
);
|
||||
const categoryAxisValue = getXAxisValue(props.data, props.name);
|
||||
if (categoryAxisValue !== undefined) {
|
||||
handleXAxisChange(categoryAxisValue);
|
||||
}
|
||||
} else if (
|
||||
canCrossFilterByXAxis &&
|
||||
xAxis.type === AxisType.Time &&
|
||||
props.componentType === 'series'
|
||||
) {
|
||||
const timeAxisValue = getXAxisValue(props.data, props.name);
|
||||
if (timeAxisValue !== undefined) {
|
||||
const timestamp = getTimestampFromTimeAxisValue(timeAxisValue);
|
||||
if (timestamp !== undefined) {
|
||||
handleTimeAxisChange(timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, TIMER_DURATION);
|
||||
},
|
||||
@@ -466,17 +576,20 @@ export default function EchartsTimeseries({
|
||||
];
|
||||
const groupBy = ensureIsArray(formData.groupby);
|
||||
if (data && xAxis.type === AxisType.Time) {
|
||||
drillToDetailFilters.push({
|
||||
col:
|
||||
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
|
||||
xAxis.label === DTTM_ALIAS
|
||||
? formData.granularitySqla
|
||||
: xAxis.label,
|
||||
grain: formData.timeGrainSqla,
|
||||
op: '==',
|
||||
val: data[0],
|
||||
formattedVal: xValueFormatter(data[0]),
|
||||
});
|
||||
const timeAxisValue = getXAxisValue(data, eventParams.name);
|
||||
if (timeAxisValue !== undefined) {
|
||||
drillToDetailFilters.push({
|
||||
col:
|
||||
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
|
||||
xAxis.label === DTTM_ALIAS
|
||||
? formData.granularitySqla
|
||||
: xAxis.label,
|
||||
grain: resolvedTimeGrain,
|
||||
op: '==',
|
||||
val: timeAxisValue,
|
||||
formattedVal: xValueFormatter(timeAxisValue),
|
||||
});
|
||||
}
|
||||
}
|
||||
[
|
||||
...(xAxis.type === AxisType.Category && data ? [xAxis.label] : []),
|
||||
@@ -517,9 +630,7 @@ export default function EchartsTimeseries({
|
||||
xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
|
||||
if (data && xAxis.type === AxisType.Time && xAxisCol) {
|
||||
// For horizontal orientation the [x, value] pair is swapped
|
||||
const xValue = Array.isArray(data)
|
||||
? data[categoryAxisValueIndex]
|
||||
: data;
|
||||
const xValue = Array.isArray(data) ? data[xAxisValueIndex] : data;
|
||||
const xAxisFilter = getTemporalXAxisDrillByFilter(
|
||||
xAxisCol,
|
||||
xValue,
|
||||
@@ -530,10 +641,7 @@ export default function EchartsTimeseries({
|
||||
xAxisFilters.push(xAxisFilter);
|
||||
}
|
||||
} else if (xAxis.type === AxisType.Category && xAxisCol) {
|
||||
const categoryAxisValue = getCategoryAxisValue(
|
||||
data,
|
||||
eventParams.name,
|
||||
);
|
||||
const categoryAxisValue = getXAxisValue(data, eventParams.name);
|
||||
if (categoryAxisValue !== undefined) {
|
||||
// A category axis can still sit on a temporal column when the
|
||||
// axis is forced categorical; filter by time bucket in that case
|
||||
@@ -564,15 +672,25 @@ export default function EchartsTimeseries({
|
||||
crossFilter = getCrossFilterDataMask(seriesName);
|
||||
} else if (
|
||||
canCrossFilterByXAxis &&
|
||||
xAxis.type === AxisType.Category &&
|
||||
eventParams.componentType === 'series'
|
||||
) {
|
||||
const categoryAxisValue = getCategoryAxisValue(
|
||||
data,
|
||||
eventParams.name,
|
||||
);
|
||||
const categoryAxisValue = getXAxisValue(data, eventParams.name);
|
||||
if (categoryAxisValue !== undefined) {
|
||||
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
|
||||
}
|
||||
} else if (
|
||||
canCrossFilterByXAxis &&
|
||||
xAxis.type === AxisType.Time &&
|
||||
eventParams.componentType === 'series'
|
||||
) {
|
||||
const timeAxisValue = getXAxisValue(data, eventParams.name);
|
||||
if (timeAxisValue !== undefined) {
|
||||
const timestamp = getTimestampFromTimeAxisValue(timeAxisValue);
|
||||
if (timestamp !== undefined) {
|
||||
crossFilter = getTimeAxisCrossFilterDataMask(timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||
@@ -593,26 +711,39 @@ export default function EchartsTimeseries({
|
||||
const { value } = event;
|
||||
if (
|
||||
canCrossFilterByXAxis &&
|
||||
event.targetType === 'axisLabel' &&
|
||||
(typeof value === 'string' || typeof value === 'number')
|
||||
) {
|
||||
handleXAxisChange(value);
|
||||
if (xAxis.type === AxisType.Time) {
|
||||
const timestamp = getTimestampFromTimeAxisValue(value);
|
||||
if (timestamp !== undefined) {
|
||||
handleTimeAxisChange(timestamp);
|
||||
}
|
||||
} else {
|
||||
handleXAxisChange(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
[canCrossFilterByXAxis, handleXAxisChange],
|
||||
[
|
||||
canCrossFilterByXAxis,
|
||||
handleTimeAxisChange,
|
||||
handleXAxisChange,
|
||||
xAxis.type,
|
||||
],
|
||||
);
|
||||
|
||||
const categoryAxis =
|
||||
const renderedXAxis =
|
||||
formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
|
||||
|
||||
const queryEventHandlers = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'click',
|
||||
query: `${categoryAxis}.category`,
|
||||
query: renderedXAxis,
|
||||
handler: handleXAxisLabelClick,
|
||||
},
|
||||
],
|
||||
[categoryAxis, handleXAxisLabelClick],
|
||||
[renderedXAxis, handleXAxisLabelClick],
|
||||
);
|
||||
|
||||
const zrEventHandlers: EventHandlers = {
|
||||
|
||||
@@ -102,15 +102,18 @@ export default function buildQuery(formData: QueryFormData) {
|
||||
1. The resample, rolling, cum, timeCompare operators should be after pivot.
|
||||
2. Resample must come before rolling so that imputed values are
|
||||
included in the rolling window calculation.
|
||||
3. the flatOperator makes multiIndex Dataframe into flat Dataframe
|
||||
3. Contribution must come before rename because it relies on the
|
||||
`__<time offset>` suffix to compute each time shift separately,
|
||||
and rename strips that suffix.
|
||||
4. the flatOperator makes multiIndex Dataframe into flat Dataframe
|
||||
*/
|
||||
post_processing: [
|
||||
pivotOperatorInRuntime,
|
||||
resampleOperator(formData, baseQueryObject),
|
||||
rollingWindowOperator(formData, baseQueryObject),
|
||||
timeCompareOperator(formData, baseQueryObject),
|
||||
renameOperator(formData, baseQueryObject),
|
||||
contributionOperator(formData, baseQueryObject, time_offsets),
|
||||
renameOperator(formData, baseQueryObject),
|
||||
sortOperator(formData, baseQueryObject),
|
||||
flattenOperator(formData, baseQueryObject),
|
||||
// todo: move prophet before flatten
|
||||
|
||||
@@ -1248,7 +1248,7 @@ export default function transformProps(
|
||||
name: xAxisTitle,
|
||||
nameGap: convertInteger(xAxisTitleMargin),
|
||||
nameLocation: 'middle',
|
||||
...(xAxisType === AxisType.Category &&
|
||||
...((xAxisType === AxisType.Category || xAxisType === AxisType.Time) &&
|
||||
groupBy.length === 0 && {
|
||||
triggerEvent: true,
|
||||
}),
|
||||
@@ -1256,10 +1256,12 @@ export default function transformProps(
|
||||
// When rotation is applied on time axes, hideOverlap can
|
||||
// aggressively hide the last label. Rotated labels already
|
||||
// have less overlap, so disabling hideOverlap is safe.
|
||||
// At 0° rotation, keep hideOverlap to prevent long labels
|
||||
// from overlapping each other, with showMaxLabel to ensure
|
||||
// the last data point label stays visible (#37181).
|
||||
hideOverlap: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
// At 0° rotation, also disable hideOverlap when showMaxLabel
|
||||
// is active so the forced boundary label is never suppressed
|
||||
// by ECharts' overlap detection (#39899).
|
||||
hideOverlap: showMaxLabel
|
||||
? false
|
||||
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
formatter: deduplicatedFormatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
@@ -1598,6 +1600,7 @@ export default function transformProps(
|
||||
label: xAxisLabel,
|
||||
type: xAxisType,
|
||||
},
|
||||
resolvedTimeGrain,
|
||||
refs,
|
||||
coltypeMapping: dataTypes,
|
||||
onLegendScroll,
|
||||
|
||||
@@ -122,5 +122,6 @@ export type TimeseriesChartTransformedProps =
|
||||
label: string;
|
||||
type: AxisType;
|
||||
};
|
||||
resolvedTimeGrain?: TimeGranularity;
|
||||
onFocusedSeries: (series: string | null) => void;
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { sanitizeHtml } from '@superset-ui/core';
|
||||
|
||||
// =============================================================================
|
||||
// Common Schemas
|
||||
@@ -57,6 +58,33 @@ const fontStyleSchema = z.enum(['normal', 'italic', 'oblique']);
|
||||
/** Symbol type */
|
||||
const symbolTypeSchema = z.string();
|
||||
|
||||
/**
|
||||
* With the ECharts default renderMode 'html', a string tooltip formatter is
|
||||
* assigned to the tooltip DOM element via innerHTML. ECharts formatter
|
||||
* strings commonly rely on inline markup (e.g. '{b}<br/>{c}') for layout, so
|
||||
* rejecting every '<' would break that supported usage; instead the value is
|
||||
* run through the same allowlist sanitizer used for other tooltip HTML,
|
||||
* which keeps presentational tags and strips anything else.
|
||||
*/
|
||||
const sanitizedFormatterSchema = z
|
||||
.string()
|
||||
.transform(value => sanitizeHtml(value));
|
||||
|
||||
/**
|
||||
* ECharts navigates to title.link/sublink on click, so restrict them to
|
||||
* http(s) and same-origin relative paths.
|
||||
*/
|
||||
const safeLinkSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
value =>
|
||||
/^https?:\/\//i.test(value) ||
|
||||
(value.startsWith('/') && !value.startsWith('//')),
|
||||
{
|
||||
message: 'Only http(s) or same-origin relative URLs are allowed',
|
||||
},
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Text Style Schema
|
||||
// =============================================================================
|
||||
@@ -168,11 +196,11 @@ export const titleSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
show: z.boolean().optional(),
|
||||
text: z.string().optional(),
|
||||
link: z.string().optional(),
|
||||
link: safeLinkSchema.optional(),
|
||||
target: z.enum(['self', 'blank']).optional(),
|
||||
textStyle: textStyleSchema.optional(),
|
||||
subtext: z.string().optional(),
|
||||
sublink: z.string().optional(),
|
||||
sublink: safeLinkSchema.optional(),
|
||||
subtarget: z.enum(['self', 'blank']).optional(),
|
||||
subtextStyle: textStyleSchema.optional(),
|
||||
textAlign: z.enum(['left', 'center', 'right']).optional(),
|
||||
@@ -386,7 +414,9 @@ export const tooltipSchema = z.object({
|
||||
z.array(z.union([z.number(), z.string()])),
|
||||
])
|
||||
.optional(),
|
||||
formatter: z.string().optional(), // Only string formatters
|
||||
// Only string formatters: a string tooltip formatter is rendered via
|
||||
// innerHTML (default renderMode 'html'), so it is sanitized above.
|
||||
formatter: sanitizedFormatterSchema.optional(),
|
||||
padding: z.union([z.number(), z.array(z.number())]).optional(),
|
||||
backgroundColor: colorSchema.optional(),
|
||||
borderColor: colorSchema.optional(),
|
||||
@@ -397,7 +427,9 @@ export const tooltipSchema = z.object({
|
||||
shadowOffsetX: z.number().optional(),
|
||||
shadowOffsetY: z.number().optional(),
|
||||
textStyle: textStyleSchema.optional(),
|
||||
extraCssText: z.string().optional(),
|
||||
// `extraCssText` is intentionally not accepted; unknown keys are
|
||||
// stripped by the schema, so configs that still carry it keep working
|
||||
// minus the raw CSS.
|
||||
order: z
|
||||
.enum(['seriesAsc', 'seriesDesc', 'valueAsc', 'valueDesc'])
|
||||
.optional(),
|
||||
@@ -575,6 +607,9 @@ export const seriesSchema = z.object({
|
||||
polarIndex: z.number().optional(),
|
||||
geoIndex: z.number().optional(),
|
||||
calendarIndex: z.number().optional(),
|
||||
// Per-series `tooltip` is intentionally not admitted; the schema
|
||||
// strips unknown keys. If per-series tooltips are ever admitted, reuse
|
||||
// tooltipSchema so the formatter sanitization applies.
|
||||
label: labelSchema.optional(),
|
||||
labelLine: z
|
||||
.object({
|
||||
|
||||
+70
@@ -523,3 +523,73 @@ test('EChartOptionsParseError contains validation error details', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Creator-authored options must not reach the tooltip's innerHTML/
|
||||
// navigation sinks unsanitized.
|
||||
// =============================================================================
|
||||
|
||||
test('sanitizes tooltip string formatters instead of rejecting all markup', () => {
|
||||
const input = `{ tooltip: { formatter: '<img src=x onerror=alert(1)>' } }`;
|
||||
const result = parseEChartOptions(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.tooltip).toEqual({ formatter: '<img src>' });
|
||||
});
|
||||
|
||||
test('keeps presentational tags in tooltip string formatters', () => {
|
||||
const input = `{ tooltip: { formatter: '{b}<br/>{c}' } }`;
|
||||
const result = parseEChartOptions(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.tooltip).toEqual({ formatter: '{b}<br />{c}' });
|
||||
});
|
||||
|
||||
test('strips per-series tooltip config so its formatter never reaches the merge', () => {
|
||||
const result = parseEChartOptions(
|
||||
`{ series: [{ type: 'line', tooltip: { formatter: '<b onpointerover=alert(1)>x</b>' } }] }`,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ series: [{ type: 'line' }] });
|
||||
});
|
||||
|
||||
test('accepts markup-free tooltip placeholder formatters', () => {
|
||||
const input = `{ tooltip: { formatter: '{b}: {c}' } }`;
|
||||
const result = parseEChartOptions(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ tooltip: { formatter: '{b}: {c}' } });
|
||||
});
|
||||
|
||||
test('rejects javascript: URLs in title link and sublink', () => {
|
||||
expect(() =>
|
||||
parseEChartOptions(`{ title: { link: 'javascript:alert(1)' } }`),
|
||||
).toThrow(EChartOptionsParseError);
|
||||
expect(() =>
|
||||
parseEChartOptions(`{ title: { sublink: 'javascript:alert(1)' } }`),
|
||||
).toThrow(EChartOptionsParseError);
|
||||
expect(() =>
|
||||
parseEChartOptions(`{ title: { link: '//evil.example/x' } }`),
|
||||
).toThrow(EChartOptionsParseError);
|
||||
});
|
||||
|
||||
test('accepts http(s) and same-origin relative title links', () => {
|
||||
const result = parseEChartOptions(
|
||||
`{ title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' } }`,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({
|
||||
title: { link: 'https://superset.apache.org', sublink: '/dashboard/1/' },
|
||||
});
|
||||
});
|
||||
|
||||
test('strips tooltip extraCssText instead of passing raw CSS through', () => {
|
||||
const result = parseEChartOptions(
|
||||
`{ tooltip: { show: true, extraCssText: 'background:url(//evil.example/x)' } }`,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ tooltip: { show: true } });
|
||||
});
|
||||
|
||||
+104
@@ -1165,6 +1165,110 @@ test('x-axis dedup keeps the forced min label when the endpoints format identica
|
||||
expect(formatter(min)).toBe('May');
|
||||
});
|
||||
|
||||
test('#39899 - x-axis dates do not overlap and last label stays visible at 0° rotation (mixed)', () => {
|
||||
// When showMaxLabel is active on a time axis with 0° rotation,
|
||||
// hideOverlap must be off so ECharts cannot suppress the forced
|
||||
// max label (the end-of-axis date).
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [
|
||||
createTestQueryData(
|
||||
[
|
||||
{
|
||||
__timestamp: Date.UTC(2026, 0, 1),
|
||||
sum__num: 100,
|
||||
},
|
||||
{
|
||||
__timestamp: Date.UTC(2026, 6, 1),
|
||||
sum__num: 200,
|
||||
},
|
||||
],
|
||||
{
|
||||
colnames: ['__timestamp', 'sum__num'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
|
||||
},
|
||||
),
|
||||
createTestQueryData(
|
||||
[
|
||||
{
|
||||
__timestamp: Date.UTC(2026, 0, 1),
|
||||
sum__num: 100,
|
||||
},
|
||||
{
|
||||
__timestamp: Date.UTC(2026, 6, 1),
|
||||
sum__num: 200,
|
||||
},
|
||||
],
|
||||
{
|
||||
colnames: ['__timestamp', 'sum__num'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
|
||||
},
|
||||
),
|
||||
],
|
||||
formData: {
|
||||
...formData,
|
||||
x_axis: '__timestamp',
|
||||
metrics: ['sum__num'],
|
||||
metricsB: ['sum__num'],
|
||||
groupby: [],
|
||||
groupbyB: [],
|
||||
xAxisLabelRotation: 0,
|
||||
// showMaxLabel (and therefore hideOverlap: false) only activates when
|
||||
// a time grain resolves, so this needs one set to actually exercise
|
||||
// the #39899 fix rather than silently no-op.
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
[
|
||||
{
|
||||
__timestamp: Date.UTC(2026, 0, 1),
|
||||
sum__num: 100,
|
||||
},
|
||||
{
|
||||
__timestamp: Date.UTC(2026, 6, 1),
|
||||
sum__num: 200,
|
||||
},
|
||||
],
|
||||
{
|
||||
colnames: ['__timestamp', 'sum__num'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
|
||||
},
|
||||
),
|
||||
createTestQueryData(
|
||||
[
|
||||
{
|
||||
__timestamp: Date.UTC(2026, 0, 1),
|
||||
sum__num: 100,
|
||||
},
|
||||
{
|
||||
__timestamp: Date.UTC(2026, 6, 1),
|
||||
sum__num: 200,
|
||||
},
|
||||
],
|
||||
{
|
||||
colnames: ['__timestamp', 'sum__num'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: { __timestamp: ['__timestamp'], sum__num: ['sum__num'] },
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const { axisLabel } = echartOptions.xAxis as Record<string, any>;
|
||||
|
||||
expect(axisLabel.showMaxLabel).toBe(true);
|
||||
expect(axisLabel.alignMaxLabel).toBe('right');
|
||||
expect(axisLabel.hideOverlap).toBe(false);
|
||||
});
|
||||
|
||||
test('regression #37921: multi-metric Query A with groupby does not duplicate first metric in series names', () => {
|
||||
// Regression test for https://github.com/apache/superset/issues/37921
|
||||
// ("Residual" follow-up to #37055).
|
||||
|
||||
@@ -70,4 +70,42 @@ describe('renderNormalizedTooltip', () => {
|
||||
expect(tooltip).toContain('N/A');
|
||||
expect(tooltip).not.toContain('NaN');
|
||||
});
|
||||
|
||||
test('should HTML-escape series names from query data', () => {
|
||||
// Regression test: the tooltip is rendered via innerHTML, so markup
|
||||
// in query-result values must not become live DOM.
|
||||
const tooltip = renderNormalizedTooltip(
|
||||
{ ...params, name: '<img src=x onerror=alert(1)>' },
|
||||
metrics,
|
||||
mockGetDenormalizedValue,
|
||||
metricsWithCustomBounds,
|
||||
);
|
||||
expect(tooltip).not.toContain('<img');
|
||||
expect(tooltip).toContain('<img');
|
||||
});
|
||||
|
||||
test('should HTML-escape metric labels', () => {
|
||||
const tooltip = renderNormalizedTooltip(
|
||||
params,
|
||||
['<svg onload=alert(1)>', 'metric2'],
|
||||
mockGetDenormalizedValue,
|
||||
metricsWithCustomBounds,
|
||||
);
|
||||
expect(tooltip).not.toContain('<svg');
|
||||
expect(tooltip).toContain('<svg');
|
||||
});
|
||||
|
||||
test('should HTML-escape the series color used for the tooltip color dot', () => {
|
||||
// Regression test: `color` is interpolated into a style attribute
|
||||
// unquoted, so an unescaped quote could break out of the attribute
|
||||
// and inject markup.
|
||||
const tooltip = renderNormalizedTooltip(
|
||||
{ ...params, color: 'red" onmouseover="alert(1)' },
|
||||
metrics,
|
||||
mockGetDenormalizedValue,
|
||||
metricsWithCustomBounds,
|
||||
);
|
||||
expect(tooltip).not.toContain('" onmouseover="alert(1)"');
|
||||
expect(tooltip).toContain('" onmouseover="alert(1)');
|
||||
});
|
||||
});
|
||||
|
||||
+422
-4
@@ -16,11 +16,17 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { AxisType } from '@superset-ui/core';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { AxisType, DTTM_ALIAS, TimeGranularity } from '@superset-ui/core';
|
||||
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||
import EchartsTimeseries from '../../src/Timeseries/EchartsTimeseries';
|
||||
import { TimeseriesChartTransformedProps } from '../../src/Timeseries/types';
|
||||
import {
|
||||
OrientationType,
|
||||
TimeseriesChartTransformedProps,
|
||||
} from '../../src/Timeseries/types';
|
||||
import type { EchartsProps } from '../../src/types';
|
||||
|
||||
// Percent-change draggable baseline: this is the one piece of the ECharts
|
||||
// rebuilds with zero prior test coverage despite six separate production
|
||||
@@ -40,12 +46,14 @@ let mockChart: {
|
||||
convertFromPixel: jest.Mock;
|
||||
getModel: jest.Mock;
|
||||
};
|
||||
const mockEchart = jest.fn();
|
||||
|
||||
jest.mock('../../src/components/Echart', () => {
|
||||
const { forwardRef, useImperativeHandle } = jest.requireActual('react');
|
||||
return {
|
||||
__esModule: true,
|
||||
default: forwardRef((_props: unknown, ref: unknown) => {
|
||||
default: forwardRef((props: unknown, ref: unknown) => {
|
||||
mockEchart(props);
|
||||
useImperativeHandle(ref, () => ({
|
||||
getEchartInstance: () => mockChart,
|
||||
}));
|
||||
@@ -115,6 +123,17 @@ function renderTimeseries(
|
||||
);
|
||||
}
|
||||
|
||||
function getLatestEchartProps() {
|
||||
const lastCall = mockEchart.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
const [props] = lastCall as [EchartsProps];
|
||||
return props;
|
||||
}
|
||||
|
||||
function advanceClickTimer() {
|
||||
jest.advanceTimersByTime(300);
|
||||
}
|
||||
|
||||
// Pulls the graphic descriptor for the draggable baseline handle out of the
|
||||
// most recent setOption call, mirroring how ECharts itself would read it.
|
||||
function getBaselineGraphic() {
|
||||
@@ -126,6 +145,7 @@ function getBaselineGraphic() {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockEchart.mockReset();
|
||||
setupChartMock();
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
|
||||
cb(0);
|
||||
@@ -134,6 +154,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -245,3 +266,400 @@ test('does not touch the chart instance when rebase is disabled', () => {
|
||||
|
||||
expect(mockChart.setOption).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('emits TEMPORAL_RANGE cross-filter from time axis label click on day bucket', () => {
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
renderTimeseries({
|
||||
emitCrossFilters: true,
|
||||
setDataMask,
|
||||
groupby: [],
|
||||
resolvedTimeGrain: TimeGranularity.DAY,
|
||||
formData: {
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.DAY,
|
||||
vizType: 'echarts_timeseries_line',
|
||||
} as any,
|
||||
xAxis: {
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
});
|
||||
|
||||
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
|
||||
({ query }) => query === 'xAxis',
|
||||
)?.handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
targetType: 'axisLabel',
|
||||
value: '2021-01-01',
|
||||
} as unknown as ECElementEvent);
|
||||
|
||||
expect(setDataMask.mock.calls[0][0]).toEqual({
|
||||
extraFormData: {
|
||||
filters: [
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
filterState: {
|
||||
label: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
|
||||
value: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
|
||||
selectedValues: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('emits upper-exclusive TEMPORAL_RANGE from time point click on month bucket', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
renderTimeseries({
|
||||
emitCrossFilters: true,
|
||||
setDataMask,
|
||||
groupby: [],
|
||||
resolvedTimeGrain: TimeGranularity.MONTH,
|
||||
formData: {
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
vizType: 'echarts_timeseries_line',
|
||||
} as any,
|
||||
xAxis: {
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
});
|
||||
|
||||
getLatestEchartProps().eventHandlers?.click?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses resolved time grain for temporal point-click cross-filter', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
renderTimeseries({
|
||||
emitCrossFilters: true,
|
||||
setDataMask,
|
||||
groupby: [],
|
||||
resolvedTimeGrain: TimeGranularity.MONTH,
|
||||
formData: {
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.DAY,
|
||||
extraFormData: {
|
||||
time_grain_sqla: TimeGranularity.MONTH,
|
||||
},
|
||||
vizType: 'echarts_timeseries_line',
|
||||
} as any,
|
||||
xAxis: {
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
});
|
||||
|
||||
getLatestEchartProps().eventHandlers?.click?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emits TEMPORAL_RANGE from string-typed time point click value', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
renderTimeseries({
|
||||
emitCrossFilters: true,
|
||||
setDataMask,
|
||||
groupby: [],
|
||||
resolvedTimeGrain: TimeGranularity.MONTH,
|
||||
formData: {
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
vizType: 'echarts_timeseries_line',
|
||||
} as any,
|
||||
xAxis: {
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
});
|
||||
|
||||
getLatestEchartProps().eventHandlers?.click?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: ['2021-01-01T00:00:00Z', 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emits TEMPORAL_RANGE from horizontal time point click using timestamp, not metric', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
renderTimeseries({
|
||||
emitCrossFilters: true,
|
||||
setDataMask,
|
||||
groupby: [],
|
||||
resolvedTimeGrain: TimeGranularity.MONTH,
|
||||
formData: {
|
||||
orientation: OrientationType.Horizontal,
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
vizType: 'echarts_timeseries_line',
|
||||
} as any,
|
||||
xAxis: {
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
});
|
||||
|
||||
getLatestEchartProps().eventHandlers?.click?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [129, Date.UTC(2021, 0, 1)],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('clears temporal X-axis cross-filter when clicking selected bucket again', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMask = jest.fn();
|
||||
const selectedRange = '2021-01-01T00:00:00 : 2021-02-01T00:00:00';
|
||||
|
||||
renderTimeseries({
|
||||
emitCrossFilters: true,
|
||||
setDataMask,
|
||||
groupby: [],
|
||||
selectedValues: { 0: selectedRange },
|
||||
resolvedTimeGrain: TimeGranularity.MONTH,
|
||||
formData: {
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
vizType: 'echarts_timeseries_line',
|
||||
} as any,
|
||||
xAxis: {
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
});
|
||||
|
||||
getLatestEchartProps().eventHandlers?.click?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMask.mock.calls[0][0]).toEqual({
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
filterState: {
|
||||
label: undefined,
|
||||
value: null,
|
||||
selectedValues: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('emits empty temporal X-axis data mask when filter grain is missing', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
renderTimeseries({
|
||||
emitCrossFilters: true,
|
||||
setDataMask,
|
||||
groupby: [],
|
||||
formData: {
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: undefined,
|
||||
vizType: 'echarts_timeseries_line',
|
||||
} as any,
|
||||
xAxis: {
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
});
|
||||
|
||||
getLatestEchartProps().eventHandlers?.click?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
dataIndex: 0,
|
||||
});
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMask.mock.calls[0][0]).toEqual({
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
filterState: {
|
||||
label: undefined,
|
||||
value: null,
|
||||
selectedValues: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('warns and skips temporal cross-filter when string value cannot be parsed', () => {
|
||||
jest.useFakeTimers();
|
||||
const setDataMask = jest.fn();
|
||||
const warn = jest.spyOn(logging, 'warn').mockImplementation();
|
||||
|
||||
renderTimeseries({
|
||||
emitCrossFilters: true,
|
||||
setDataMask,
|
||||
groupby: [],
|
||||
resolvedTimeGrain: TimeGranularity.MONTH,
|
||||
formData: {
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
vizType: 'echarts_timeseries_line',
|
||||
} as any,
|
||||
xAxis: {
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
});
|
||||
|
||||
getLatestEchartProps().eventHandlers?.click?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: ['not-a-date', 100],
|
||||
name: 'not-a-date',
|
||||
dataIndex: 0,
|
||||
});
|
||||
advanceClickTimer();
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Unable to parse time axis value for cross-filtering',
|
||||
'not-a-date',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not emit temporal X-axis label cross-filter when dimensions are set', () => {
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
renderTimeseries({
|
||||
emitCrossFilters: true,
|
||||
setDataMask,
|
||||
groupby: ['country'],
|
||||
formData: {
|
||||
groupby: ['country'],
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
vizType: 'echarts_timeseries_line',
|
||||
} as any,
|
||||
xAxis: {
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
});
|
||||
|
||||
const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
|
||||
({ query }) => query === 'xAxis',
|
||||
)?.handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
targetType: 'axisLabel',
|
||||
value: '2021-01-01',
|
||||
} as unknown as ECElementEvent);
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('context menu cross-filter is available for a temporal bar point', async () => {
|
||||
const onContextMenu = jest.fn();
|
||||
|
||||
renderTimeseries({
|
||||
emitCrossFilters: true,
|
||||
onContextMenu,
|
||||
groupby: [],
|
||||
resolvedTimeGrain: TimeGranularity.MONTH,
|
||||
formData: {
|
||||
granularitySqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.DAY,
|
||||
extraFormData: {
|
||||
time_grain_sqla: TimeGranularity.MONTH,
|
||||
},
|
||||
vizType: 'echarts_timeseries_line',
|
||||
} as any,
|
||||
xAxis: {
|
||||
label: DTTM_ALIAS,
|
||||
type: AxisType.Time,
|
||||
},
|
||||
});
|
||||
|
||||
await getLatestEchartProps().eventHandlers?.contextmenu?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [Date.UTC(2021, 0, 1), 100],
|
||||
name: '2021-01-01',
|
||||
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const { crossFilter } = onContextMenu.mock.calls[0][2];
|
||||
expect(crossFilter.dataMask.extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'ds',
|
||||
op: 'TEMPORAL_RANGE',
|
||||
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -64,6 +64,28 @@ describe('Timeseries buildQuery', () => {
|
||||
expect(query.metrics).toEqual(['bar', 'baz']);
|
||||
});
|
||||
|
||||
test('should apply contribution before rename with time comparison', () => {
|
||||
// rename strips the `__<offset>` suffix that contribution relies on to
|
||||
// compute each time shift separately
|
||||
const queryContext = buildQuery({
|
||||
...formData,
|
||||
metrics: ['bar'],
|
||||
x_axis: 'ds',
|
||||
groupby: ['col1'],
|
||||
contributionMode: 'row',
|
||||
comparison_type: 'values',
|
||||
time_compare: ['1 week ago'],
|
||||
});
|
||||
const [query] = queryContext.queries;
|
||||
const operations = (query.post_processing || []).map(
|
||||
operator => operator?.operation,
|
||||
);
|
||||
expect(operations).toContain('contribution');
|
||||
expect(operations.indexOf('contribution')).toBeLessThan(
|
||||
operations.indexOf('rename'),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not order by timeseries limit if orderby provided', () => {
|
||||
const queryContext = buildQuery({
|
||||
...formData,
|
||||
|
||||
@@ -2083,6 +2083,39 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
|
||||
expect(xAxis.triggerEvent).toBe(true);
|
||||
});
|
||||
|
||||
test('temporal x-axis enables trigger events when no dimensions are set', () => {
|
||||
const ts1 = 1745784000000;
|
||||
const ts2 = 1745870400000;
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
metrics: ['metric'],
|
||||
granularity_sqla: 'ds',
|
||||
x_axis: '__timestamp',
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
[
|
||||
{ __timestamp: ts1, metric: 10 },
|
||||
{ __timestamp: ts2, metric: 20 },
|
||||
],
|
||||
{
|
||||
colnames: ['__timestamp', 'metric'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const xAxis = echartOptions.xAxis as {
|
||||
triggerEvent?: boolean;
|
||||
type: string;
|
||||
};
|
||||
|
||||
expect(xAxis.type).toBe(AxisType.Time);
|
||||
expect(xAxis.triggerEvent).toBe(true);
|
||||
});
|
||||
|
||||
test('temporal x coltype forced categorical yields a Category axis with date labels', () => {
|
||||
// Issue #28204: with a temporal x-axis (e.g. weekly grain) the default Time
|
||||
// scale places ticks at "nice" intervals that don't line up with the buckets.
|
||||
@@ -2330,6 +2363,7 @@ test('tooltip time grain wiring: dashboard-level extraFormData time grain overri
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
expect(transformedProps.resolvedTimeGrain).toBe(TimeGranularity.MONTH);
|
||||
const tooltipFormatter = (
|
||||
transformedProps.echartOptions as unknown as TooltipFormatterOptions
|
||||
).tooltip.formatter;
|
||||
@@ -2363,6 +2397,7 @@ test('tooltip time grain wiring: chart-level time grain drives the tooltip when
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(chartProps);
|
||||
expect(transformedProps.resolvedTimeGrain).toBe(TimeGranularity.YEAR);
|
||||
const tooltipFormatter = (
|
||||
transformedProps.echartOptions as unknown as TooltipFormatterOptions
|
||||
).tooltip.formatter;
|
||||
|
||||
@@ -341,15 +341,15 @@ test('should configure time axis labels to show max label for last month visibil
|
||||
);
|
||||
});
|
||||
|
||||
test('x-axis dates do not overlap and last label stays visible at 0° rotation', () => {
|
||||
test('#39899 - x-axis dates do not overlap and last label stays visible at 0° rotation', () => {
|
||||
const result = transformProps(buildTimeseriesChartProps());
|
||||
const { axisLabel } = result.echartOptions.xAxis as Record<string, any>;
|
||||
|
||||
expect(axisLabel.hideOverlap).toBe(true);
|
||||
// showMaxLabel forces the last data point label to render even
|
||||
// when hideOverlap is active, preventing the #37181 regression.
|
||||
// showMaxLabel forces the last data point label to render
|
||||
expect(axisLabel.showMaxLabel).toBe(true);
|
||||
expect(axisLabel.alignMaxLabel).toBe('right');
|
||||
// hideOverlap must be OFF so ECharts cannot suppress the forced max label
|
||||
expect(axisLabel.hideOverlap).toBe(false);
|
||||
});
|
||||
|
||||
test('last x-axis date is visible and not cut off when rotated -45°', () => {
|
||||
|
||||
@@ -80,6 +80,19 @@ interface DatamapSource {
|
||||
country?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS attacks. Popup templates are
|
||||
* assigned to the hover element via innerHTML by the datamaps library, and
|
||||
* formatter output can echo a creator-controlled format string verbatim
|
||||
* (see createD3NumberFormatter's invalid-format fallback), so both the name
|
||||
* and the formatted value must be treated as untrusted text.
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const propTypes = {
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
@@ -279,9 +292,9 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
|
||||
highlightBorderWidth: 1,
|
||||
popupTemplate: (geo, d) =>
|
||||
d &&
|
||||
`<div class="hoverinfo"><strong>${d.name}</strong><br>${formatter(
|
||||
d.m1,
|
||||
)}</div>`,
|
||||
`<div class="hoverinfo"><strong>${escapeHtml(
|
||||
d.name,
|
||||
)}</strong><br>${escapeHtml(String(formatter(d.m1)))}</div>`,
|
||||
},
|
||||
bubblesConfig: {
|
||||
borderWidth: 1,
|
||||
@@ -290,9 +303,9 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
|
||||
popupOnHover: !inContextMenu,
|
||||
radius: null,
|
||||
popupTemplate: (geo, d) =>
|
||||
`<div class="hoverinfo"><strong>${d.name}</strong><br>${formatter(
|
||||
d.m2,
|
||||
)}</div>`,
|
||||
`<div class="hoverinfo"><strong>${escapeHtml(
|
||||
d.name,
|
||||
)}</strong><br>${escapeHtml(String(formatter(d.m2)))}</div>`,
|
||||
fillOpacity: 0.5,
|
||||
animate: true,
|
||||
highlightOnHover: !inContextMenu,
|
||||
|
||||
@@ -180,6 +180,33 @@ test('disables Datamaps highlightOnHover while the context menu is open', () =>
|
||||
expect(geographyConfig?.highlightOnHover).toBe(false);
|
||||
});
|
||||
|
||||
test('escapes markup in hover popup templates', () => {
|
||||
// Regression test for stored XSS via the number-formatter fallback: an
|
||||
// invalid Y Axis Format string is echoed verbatim by the formatter
|
||||
// (createD3NumberFormatter's catch branch), so the popup templates must
|
||||
// HTML-escape formatter output before datamaps assigns it via innerHTML.
|
||||
const maliciousFormatter = getNumberFormatter('<img src=x onerror=alert(1)>');
|
||||
WorldMap(container, { ...baseProps, formatter: maliciousFormatter });
|
||||
|
||||
const geographyConfig = lastDatamapConfig?.geographyConfig as {
|
||||
popupTemplate: (geo: unknown, d: unknown) => string;
|
||||
};
|
||||
const bubblesConfig = lastDatamapConfig?.bubblesConfig as {
|
||||
popupTemplate: (geo: unknown, d: unknown) => string;
|
||||
};
|
||||
const entry = { name: '<b>United States</b>', m1: 100, m2: 200 };
|
||||
|
||||
const geoPopup = geographyConfig.popupTemplate({}, entry);
|
||||
const bubblePopup = bubblesConfig.popupTemplate({}, entry);
|
||||
|
||||
[geoPopup, bubblePopup].forEach(popup => {
|
||||
expect(popup).not.toContain('<img');
|
||||
expect(popup).not.toContain('<b>');
|
||||
expect(popup).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(popup).toContain('<b>United States</b>');
|
||||
});
|
||||
});
|
||||
|
||||
test('does not throw error when onContextMenu is undefined', () => {
|
||||
const propsWithoutContextMenu = {
|
||||
...baseProps,
|
||||
|
||||
@@ -1524,7 +1524,9 @@ export function popPermalink(key: string): SqlLabThunkAction<Promise<unknown>> {
|
||||
dbId: json.dbId ? parseInt(json.dbId, 10) : undefined,
|
||||
catalog: json.catalog ?? null,
|
||||
schema: json.schema ?? undefined,
|
||||
autorun: json.autorun ? json.autorun : false,
|
||||
// The recipient must review the prefilled query and press
|
||||
// Run; a permalink payload never auto-runs.
|
||||
autorun: false,
|
||||
sql: json.sql ? json.sql : 'SELECT ...',
|
||||
templateParams: json.templateParams,
|
||||
}),
|
||||
@@ -1548,7 +1550,9 @@ export function popStoredQuery(
|
||||
dbId: json.dbId ? parseInt(json.dbId, 10) : undefined,
|
||||
catalog: json.catalog ?? null,
|
||||
schema: json.schema ?? undefined,
|
||||
autorun: json.autorun ? json.autorun : false,
|
||||
// Same rule as popPermalink above — stored payloads never
|
||||
// auto-run.
|
||||
autorun: false,
|
||||
sql: json.sql ? json.sql : 'SELECT ...',
|
||||
templateParams: json.templateParams,
|
||||
}),
|
||||
@@ -1627,7 +1631,9 @@ export function popDatasourceQuery(
|
||||
name: `${QUERY_TEXT} ${json.result.name}`,
|
||||
dbId: json.result.database.id,
|
||||
schema: json.result.schema,
|
||||
autorun: sql !== undefined,
|
||||
// `sql` here can come straight from the URL, so its mere
|
||||
// presence must never imply auto-execution.
|
||||
autorun: false,
|
||||
sql: sql || json.result.select_star,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -719,7 +719,7 @@ const ResultSet = ({
|
||||
if (data && data.length > 0) {
|
||||
const allowHTML = getItem(
|
||||
LocalStorageKeys.SqllabIsRenderHtmlEnabled,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
const tableProps = {
|
||||
|
||||
@@ -284,7 +284,7 @@ const SqlEditor: FC<Props> = ({
|
||||
getItem(LocalStorageKeys.SqllabIsAutocompleteEnabled, true),
|
||||
);
|
||||
const [renderHTMLEnabled, setRenderHTMLEnabled] = useState(
|
||||
getItem(LocalStorageKeys.SqllabIsRenderHtmlEnabled, true),
|
||||
getItem(LocalStorageKeys.SqllabIsRenderHtmlEnabled, false),
|
||||
);
|
||||
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
|
||||
const [createAs, setCreateAs] = useState('');
|
||||
|
||||
-1
@@ -1993,7 +1993,6 @@ function DatasourceEditor({
|
||||
col => col.column_name,
|
||||
)}
|
||||
height={300}
|
||||
allowHTML
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const FilterableTable = ({
|
||||
height,
|
||||
filterText = '',
|
||||
expandedColumns = [],
|
||||
allowHTML = true,
|
||||
allowHTML = false,
|
||||
striped,
|
||||
themeOverrides,
|
||||
}: FilterableTableProps) => {
|
||||
|
||||
@@ -64,6 +64,35 @@ test('should render cellData value for default cell data', () => {
|
||||
expect(container).toHaveTextContent('regular_text');
|
||||
});
|
||||
|
||||
test('should render HTML cell data as inert text by default', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
{renderResultCell({
|
||||
cellData: '<img src="https://attacker.example/beacon.gif" />link',
|
||||
columnKey: 'a',
|
||||
})}
|
||||
</>,
|
||||
);
|
||||
expect(container.querySelector('img')).not.toBeInTheDocument();
|
||||
expect(container).toHaveTextContent(
|
||||
'<img src="https://attacker.example/beacon.gif" />link',
|
||||
);
|
||||
});
|
||||
|
||||
test('should render sanitized HTML only when allowHTML is explicitly enabled', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
{renderResultCell({
|
||||
cellData: '<b>bold</b>',
|
||||
columnKey: 'a',
|
||||
allowHTML: true,
|
||||
})}
|
||||
</>,
|
||||
);
|
||||
expect(container.querySelector('b')).toBeInTheDocument();
|
||||
expect(container).toHaveTextContent('bold');
|
||||
});
|
||||
|
||||
test('should transform cell data by getCellContent for the regular text', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
|
||||
@@ -32,11 +32,15 @@ type Params = CellParams & {
|
||||
getCellContent?: (args: CellParams) => string;
|
||||
};
|
||||
|
||||
// Result cells carry untrusted warehouse data, so HTML rendering is opt-in:
|
||||
// even sanitized markup keeps active capabilities (img/video fetch beacons,
|
||||
// phishing anchors), which must not activate by default for data the viewer
|
||||
// did not author.
|
||||
export const renderResultCell = ({
|
||||
cellData,
|
||||
getCellContent,
|
||||
columnKey,
|
||||
allowHTML = true,
|
||||
allowHTML = false,
|
||||
}: Params) => {
|
||||
const cellNode =
|
||||
getCellContent?.({ cellData, columnKey }) ?? String(cellData);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 {
|
||||
URL_PARAMS,
|
||||
RESERVED_CHART_URL_PARAMS,
|
||||
RESERVED_DASHBOARD_URL_PARAMS,
|
||||
} from 'src/constants';
|
||||
|
||||
test('permalinkKey is reserved on both the chart and dashboard URL param lists', () => {
|
||||
// Dashboard and explore permalinks resolve against different backend
|
||||
// KV resources/salts, so a key from one must never leak into the other's
|
||||
// URL via the reserved-params passthrough logic.
|
||||
expect(RESERVED_DASHBOARD_URL_PARAMS).toContain(URL_PARAMS.permalinkKey.name);
|
||||
expect(RESERVED_CHART_URL_PARAMS).toContain(URL_PARAMS.permalinkKey.name);
|
||||
});
|
||||
@@ -123,6 +123,7 @@ export const RESERVED_CHART_URL_PARAMS: string[] = [
|
||||
URL_PARAMS.datasourceId.name,
|
||||
URL_PARAMS.datasourceType.name,
|
||||
URL_PARAMS.datasetId.name,
|
||||
URL_PARAMS.permalinkKey.name,
|
||||
URL_PARAMS.versionHistory.name,
|
||||
];
|
||||
export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
|
||||
|
||||
@@ -259,6 +259,21 @@ describe('isUserEditorOrAdmin', () => {
|
||||
test('returns false when editors is omitted', () => {
|
||||
expect(isUserEditorOrAdmin(outsiderUser)).toEqual(false);
|
||||
});
|
||||
|
||||
test('returns true when the user is granted editorship only through extra_editors', () => {
|
||||
expect(isUserEditorOrAdmin(editorUser, [], [10])).toEqual(true);
|
||||
});
|
||||
|
||||
test('unions editors and extra_editors rather than preferring one', () => {
|
||||
const nonMatchingSubject: Subject = { id: 999, label: 'Other', type: 1 };
|
||||
expect(isUserEditorOrAdmin(editorUser, [nonMatchingSubject], [10])).toEqual(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns false when extra_editors names other subjects', () => {
|
||||
expect(isUserEditorOrAdmin(editorUser, [], [999])).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
|
||||
@@ -55,9 +55,6 @@ export const isUserInSubjects = (
|
||||
);
|
||||
};
|
||||
|
||||
const isUserInEditors = (editors: Subject[] = []): boolean =>
|
||||
isUserInSubjects(editors);
|
||||
|
||||
export const isUserAdmin = (
|
||||
user?: UserWithPermissionsAndRoles | UndefinedUser,
|
||||
) =>
|
||||
@@ -66,10 +63,12 @@ export const isUserAdmin = (
|
||||
role => role.toLowerCase() === ADMIN_ROLE_NAME.toLowerCase(),
|
||||
);
|
||||
|
||||
/** `extraEditors` is editorship granted via a deployment's EXTRA_EDITORS_RESOLVER. */
|
||||
export const isUserEditorOrAdmin = (
|
||||
user?: UserWithPermissionsAndRoles | UndefinedUser,
|
||||
editors: Subject[] = [],
|
||||
): boolean => isUserInEditors(editors) || isUserAdmin(user);
|
||||
extraEditors?: SubjectRef[] | null,
|
||||
): boolean => isUserInSubjects(editors, extraEditors) || isUserAdmin(user);
|
||||
|
||||
/**
|
||||
* Editorship of *dashboard*, matching the server's `is_editor`: the explicit
|
||||
|
||||
@@ -17,10 +17,20 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import { isFeatureEnabled, VizType } from '@superset-ui/core';
|
||||
import { HYDRATE_CHART_NORMALIZATION } from 'src/features/versionHistory/reducer';
|
||||
import { hydrateExplore, HYDRATE_EXPLORE } from './hydrateExplore';
|
||||
import { exploreInitialData } from '../fixtures';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
beforeEach(() => mockedIsFeatureEnabled.mockReturnValue(false));
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
@@ -343,3 +353,67 @@ test('extracts currency formats from metrics in dataset', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('seeds only guarded matching-input hydration transitions', () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: { conf: { DEFAULT_TIME_FILTER: 'Last year' } },
|
||||
explore: {},
|
||||
}));
|
||||
const persisted = {
|
||||
...exploreInitialData.form_data,
|
||||
};
|
||||
delete persisted.time_range;
|
||||
const initialData = {
|
||||
...exploreInitialData,
|
||||
form_data: { ...persisted },
|
||||
slice: {
|
||||
...exploreInitialData.slice!,
|
||||
form_data: { ...persisted },
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error focused hydration fixture
|
||||
hydrateExplore(initialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: HYDRATE_CHART_NORMALIZATION,
|
||||
tracking: expect.objectContaining({
|
||||
chartId: 371,
|
||||
transitions: expect.objectContaining({
|
||||
time_range: {
|
||||
control: 'time_range',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: 'Last year',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not seed normalization metadata for dashboard overrides', () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
window.history.pushState({}, '', '/explore/?dashboard_id=12');
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: {},
|
||||
explore: {},
|
||||
}));
|
||||
|
||||
// @ts-expect-error focused hydration fixture
|
||||
hydrateExplore(exploreInitialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: HYDRATE_CHART_NORMALIZATION }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,10 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { nanoid } from 'nanoid';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { hydrateChartNormalization } from 'src/features/versionHistory/reducer';
|
||||
import { automaticNormalizationTransitions } from 'src/features/versionHistory/normalization';
|
||||
|
||||
enum ColorSchemeType {
|
||||
CATEGORICAL = 'CATEGORICAL',
|
||||
@@ -78,6 +82,8 @@ export const hydrateExplore =
|
||||
const fallbackSlice = sliceId ? sliceEntities?.slices?.[sliceId] : null;
|
||||
const initialSlice = slice ?? fallbackSlice;
|
||||
const initialFormData = form_data ?? initialSlice?.form_data;
|
||||
const persistedFormData = cloneDeep(initialSlice?.form_data ?? {});
|
||||
const preHydrationFormData = cloneDeep(initialFormData ?? {});
|
||||
const isCachedFormData = getUrlParam(URL_PARAMS.formDataKey) !== null;
|
||||
const [primarySliceNameSource, fallbackSliceNameSource] = isCachedFormData
|
||||
? [initialFormData, initialSlice]
|
||||
@@ -213,6 +219,10 @@ export const hydrateExplore =
|
||||
exploreState,
|
||||
);
|
||||
});
|
||||
const hydratedFormData = {
|
||||
...initialFormData,
|
||||
...getFormDataFromControls(exploreState.controls),
|
||||
};
|
||||
const sliceFormData = initialSlice
|
||||
? getFormDataFromControls(initialControls)
|
||||
: null;
|
||||
@@ -233,7 +243,7 @@ export const hydrateExplore =
|
||||
lastRendered: 0,
|
||||
};
|
||||
|
||||
return dispatch({
|
||||
const result = dispatch({
|
||||
type: HYDRATE_EXPLORE,
|
||||
data: {
|
||||
charts: {
|
||||
@@ -253,6 +263,28 @@ export const hydrateExplore =
|
||||
dataMask,
|
||||
},
|
||||
});
|
||||
if (
|
||||
isFeatureEnabled(FeatureFlag.VersionHistory) &&
|
||||
initialSlice?.slice_id &&
|
||||
!isCachedFormData &&
|
||||
!dashboardId &&
|
||||
getUrlParam(URL_PARAMS.vizType) === null
|
||||
) {
|
||||
dispatch(
|
||||
hydrateChartNormalization({
|
||||
chartId: initialSlice.slice_id,
|
||||
hydrationSessionId: nanoid(),
|
||||
transitions: automaticNormalizationTransitions(
|
||||
persistedFormData,
|
||||
preHydrationFormData,
|
||||
hydratedFormData,
|
||||
),
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export type HydrateExplore = {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Dispatch } from 'redux';
|
||||
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
|
||||
import {
|
||||
DatasourceType,
|
||||
isFeatureEnabled,
|
||||
QueryFormData,
|
||||
SimpleAdhocFilter,
|
||||
VizType,
|
||||
@@ -37,6 +38,13 @@ import {
|
||||
} from './saveModalActions';
|
||||
import { Operators } from '../constants';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
// Define test constants and mock data using imported types
|
||||
const sliceId = 10;
|
||||
const sliceName = 'New chart';
|
||||
@@ -92,17 +100,159 @@ const sliceResponsePayload: Partial<PayloadSlice> = {
|
||||
};
|
||||
|
||||
const sampleError = new Error('sampleError');
|
||||
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
|
||||
|
||||
jest.mock('../exploreUtils', () => ({
|
||||
buildV1ChartDataPayload: jest.fn(() => queryContext),
|
||||
}));
|
||||
|
||||
beforeEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
beforeEach(() => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('existing-chart overwrite sends only still-matching normalization metadata', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
show_legend: true,
|
||||
object_control: { a: 1, b: 2 },
|
||||
},
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: { show_legend: true as const },
|
||||
transitions: {
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true as const,
|
||||
from_value: null,
|
||||
to_present: true as const,
|
||||
to_value: 10000,
|
||||
},
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: true,
|
||||
},
|
||||
object_control: {
|
||||
control: 'object_control',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: { b: 2, a: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{ ...sliceResponsePayload, slice_id: sliceId } as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
{
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
{
|
||||
control: 'object_control',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: { b: 2, a: 1 },
|
||||
},
|
||||
]);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'BEGIN_CHART_NORMALIZATION_SAVE' }),
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'COMPLETE_CHART_NORMALIZATION_SAVE' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('matches normalization metadata against finalized payload filters', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const extraTemporalFilter = {
|
||||
expressionType: 'SIMPLE',
|
||||
clause: 'WHERE',
|
||||
subject: 'ds',
|
||||
operator: Operators.TemporalRange,
|
||||
comparator: '',
|
||||
isExtra: true,
|
||||
} as SimpleAdhocFilter;
|
||||
const savedTemporalFilter = {
|
||||
...extraTemporalFilter,
|
||||
comparator: 'No filter',
|
||||
isExtra: false,
|
||||
};
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
adhoc_filters: [extraTemporalFilter],
|
||||
},
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {
|
||||
adhoc_filters: {
|
||||
control: 'adhoc_filters',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: [savedTemporalFilter],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{ ...sliceResponsePayload, slice_id: sliceId } as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
expect.objectContaining({
|
||||
control: 'adhoc_filters',
|
||||
to_value: [savedTemporalFilter],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests updateSlice action
|
||||
*/
|
||||
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
|
||||
test('updateSlice handles success', async () => {
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
@@ -734,3 +884,97 @@ describe('getSlicePayload', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('existing-chart overwrite covers stash-removed keys as drop transitions', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
// The stash removed order_desc from active form data...
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
},
|
||||
// ...and holds it with the value it had when hidden.
|
||||
hiddenFormData: { order_desc: true },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-drop',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{
|
||||
...sliceResponsePayload,
|
||||
slice_id: sliceId,
|
||||
// Persisted params carry the key the stash removed, same value.
|
||||
form_data: { ...formData, order_desc: true },
|
||||
} as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
{
|
||||
control: 'order_desc',
|
||||
from_present: true,
|
||||
from_value: true,
|
||||
to_present: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('a stashed value the user changed before hiding is not covered', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
},
|
||||
// Stash holds a USER-edited value; persisted differs, so the removal
|
||||
// stays recorded.
|
||||
hiddenFormData: { order_desc: false },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-drop-2',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{
|
||||
...sliceResponsePayload,
|
||||
slice_id: sliceId,
|
||||
form_data: { ...formData, order_desc: true },
|
||||
} as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import { Dispatch } from 'redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
DatasourceType,
|
||||
FeatureFlag,
|
||||
isFeatureEnabled,
|
||||
type QueryFormData,
|
||||
SimpleAdhocFilter,
|
||||
SupersetClient,
|
||||
@@ -30,11 +32,25 @@ import { isEmpty } from 'lodash-es';
|
||||
import { Slice } from 'src/dashboard/types';
|
||||
import { Operators } from '../constants';
|
||||
import { buildV1ChartDataPayload } from '../exploreUtils';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
beginChartNormalizationSave,
|
||||
completeChartNormalizationSave,
|
||||
} from 'src/features/versionHistory/reducer';
|
||||
import type {
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
} from 'src/features/versionHistory/types';
|
||||
import {
|
||||
matchingAutomaticNormalizationTransitions,
|
||||
stashDropNormalizationTransitions,
|
||||
} from 'src/features/versionHistory/normalization';
|
||||
|
||||
export interface PayloadSlice extends Slice {
|
||||
params: string;
|
||||
dashboards: number[];
|
||||
query_context: string;
|
||||
normalization_changes?: AutomaticNormalizationTransitions[string][];
|
||||
}
|
||||
const ADHOC_FILTER_REGEX = /^adhoc_filters/;
|
||||
|
||||
@@ -233,21 +249,84 @@ export const updateSlice =
|
||||
new?: boolean;
|
||||
},
|
||||
) =>
|
||||
async (dispatch: Dispatch, getState: () => Partial<QueryFormData>) => {
|
||||
async (
|
||||
dispatch: Dispatch,
|
||||
getState: () => Partial<QueryFormData> & {
|
||||
versionHistory?: {
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
};
|
||||
explore?: {
|
||||
form_data?: QueryFormData;
|
||||
hiddenFormData?: Record<string, unknown>;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
const { slice_id: sliceId, editors, form_data: formDataFromSlice } = slice;
|
||||
const formData = getState().explore?.form_data;
|
||||
const initialState = getState();
|
||||
const formData = JSON.parse(
|
||||
JSON.stringify(initialState.explore?.form_data ?? {}),
|
||||
) as QueryFormData;
|
||||
const tracking = initialState.versionHistory?.chartNormalization;
|
||||
const saveAttemptId = nanoid();
|
||||
const shouldAttachNormalization =
|
||||
isFeatureEnabled(FeatureFlag.VersionHistory) &&
|
||||
tracking?.chartId === sliceId;
|
||||
if (shouldAttachNormalization) {
|
||||
dispatch(
|
||||
beginChartNormalizationSave(
|
||||
sliceId,
|
||||
tracking.hydrationSessionId,
|
||||
saveAttemptId,
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
const payload = await getSlicePayload(
|
||||
sliceName,
|
||||
formData,
|
||||
dashboards,
|
||||
editors as [],
|
||||
formDataFromSlice,
|
||||
);
|
||||
const savedFormData = JSON.parse(payload.params ?? '{}') as QueryFormData;
|
||||
// Hydration-time transitions that still hold, plus save-time drops of
|
||||
// keys the stash removed (mutually exclusive per control: a surviving
|
||||
// hydration transition implies the key is present in the payload, a
|
||||
// stash drop implies it is absent).
|
||||
const matchingTransitions = shouldAttachNormalization
|
||||
? {
|
||||
...matchingAutomaticNormalizationTransitions(
|
||||
tracking,
|
||||
savedFormData,
|
||||
),
|
||||
...stashDropNormalizationTransitions(
|
||||
(formDataFromSlice ?? {}) as Record<string, unknown>,
|
||||
initialState.explore?.hiddenFormData,
|
||||
savedFormData,
|
||||
),
|
||||
}
|
||||
: {};
|
||||
if (
|
||||
shouldAttachNormalization &&
|
||||
Object.keys(matchingTransitions).length
|
||||
) {
|
||||
payload.normalization_changes = Object.values(matchingTransitions);
|
||||
}
|
||||
const response = await SupersetClient.put({
|
||||
endpoint: `/api/v1/chart/${sliceId}`,
|
||||
jsonPayload: await getSlicePayload(
|
||||
sliceName,
|
||||
formData,
|
||||
dashboards,
|
||||
editors as [],
|
||||
formDataFromSlice,
|
||||
),
|
||||
jsonPayload: payload,
|
||||
});
|
||||
|
||||
if (shouldAttachNormalization) {
|
||||
dispatch(
|
||||
completeChartNormalizationSave(
|
||||
sliceId,
|
||||
tracking.hydrationSessionId,
|
||||
saveAttemptId,
|
||||
{},
|
||||
),
|
||||
);
|
||||
}
|
||||
dispatch(saveSliceSuccess(response.json));
|
||||
addToasts(false, sliceName, addedToDashboard).map(dispatch);
|
||||
return response.json;
|
||||
|
||||
+4
-2
@@ -19,7 +19,6 @@
|
||||
import { useMemo, useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
getTimeFormatter,
|
||||
safeHtmlSpan,
|
||||
TimeFormats,
|
||||
getMetricLabel,
|
||||
QueryFormMetric,
|
||||
@@ -92,8 +91,11 @@ export function useGridColumns(
|
||||
) {
|
||||
return timeFormatter(value);
|
||||
}
|
||||
// Render string cells as plain text: this grid shows raw
|
||||
// query results (untrusted warehouse data) to any viewer,
|
||||
// so HTML must stay inert here even after sanitization.
|
||||
if (typeof value === 'string') {
|
||||
return safeHtmlSpan(value);
|
||||
return value;
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
|
||||
+25
@@ -112,6 +112,31 @@ describe('ColorBreakpointOption', () => {
|
||||
expect(colorPreview).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should coerce non-numeric color channels instead of injecting CSS', async () => {
|
||||
// Stored form_data is opaque JSON, so a saved chart can carry a string
|
||||
// channel crafted to break out of the styled-component declaration.
|
||||
const maliciousBreakpoint = {
|
||||
id: 3,
|
||||
color: {
|
||||
r: '0,0,0,1); } body { display: none } .x { background: rgba(0' as unknown as number,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 1,
|
||||
},
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
};
|
||||
|
||||
renderComponent({ breakpoint: maliciousBreakpoint });
|
||||
|
||||
const colorPreview = await screen.findByTestId('color-preview');
|
||||
expect(colorPreview).toBeInTheDocument();
|
||||
// The malicious string is coerced to a safe numeric channel (0), so the
|
||||
// rendered rule is a plain rgba() value with no injected CSS.
|
||||
expect(colorPreview).toHaveStyle({ background: 'rgba(0, 0, 0, 1)' });
|
||||
expect(document.body).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle decimal values', async () => {
|
||||
const decimalBreakpoint: ColorBreakpointType = {
|
||||
id: 2,
|
||||
|
||||
+16
-1
@@ -31,6 +31,19 @@ const BreakpointColorPreview = styled.div`
|
||||
margin-right: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
// Chart params are stored server-side as opaque JSON, so breakpoint color
|
||||
// channels can arrive as arbitrary strings despite the compile-time numeric
|
||||
// type. The formatted color is interpolated into a styled-component template
|
||||
// (a stylesheet, not a per-property style assignment), so each channel must
|
||||
// be coerced to a plain number to keep attacker-controlled strings from
|
||||
// injecting CSS rules.
|
||||
const toRgbChannel = (channel: unknown): number => {
|
||||
const value = Number(channel);
|
||||
return Number.isFinite(value)
|
||||
? Math.min(255, Math.max(0, Math.round(value)))
|
||||
: 0;
|
||||
};
|
||||
|
||||
const ColorBreakpointOption = ({
|
||||
breakpoint,
|
||||
colorBreakpoints,
|
||||
@@ -41,7 +54,9 @@ const ColorBreakpointOption = ({
|
||||
const { color, minValue, maxValue } = breakpoint;
|
||||
|
||||
const formattedColor = color
|
||||
? `rgba(${color.r}, ${color.g}, ${color.b}, 1)`
|
||||
? `rgba(${toRgbChannel(color.r)}, ${toRgbChannel(color.g)}, ${toRgbChannel(
|
||||
color.b,
|
||||
)}, 1)`
|
||||
: '';
|
||||
|
||||
return (
|
||||
|
||||
+18
@@ -278,6 +278,24 @@ describe('VizTypeControl', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Thumbnail labels expose the full chart name via a title tooltip', async () => {
|
||||
// Labels are clamped to a fixed two-line block so every tile is the same
|
||||
// height; the full (possibly truncated) name must stay discoverable through
|
||||
// the title attribute.
|
||||
await waitForRenderWrapper();
|
||||
userEvent.click(screen.getByRole('tab', { name: 'All charts' }));
|
||||
|
||||
const visualizations = screen.getByTestId(getTestId('viz-row'));
|
||||
const labels = await within(visualizations).findAllByTestId(
|
||||
getTestId('viztype-label'),
|
||||
);
|
||||
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
labels.forEach(label => {
|
||||
expect(label).toHaveAttribute('title', label.textContent ?? '');
|
||||
});
|
||||
});
|
||||
|
||||
test('Submit on viz type double-click', async () => {
|
||||
await waitForRenderWrapper();
|
||||
userEvent.click(screen.getByRole('tab', { name: 'All charts' }));
|
||||
|
||||
+43
-17
@@ -214,6 +214,9 @@ const IconsPane = styled.div`
|
||||
justify-content: space-evenly;
|
||||
grid-gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
justify-items: center;
|
||||
/* top-align every tile so a longer chart name never pushes the thumbnails
|
||||
of the other tiles in the same row upward */
|
||||
align-items: start;
|
||||
/* for some reason this padding doesn't seem to apply at the bottom of the container. Why is a mystery. */
|
||||
padding: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
@@ -274,7 +277,6 @@ const thumbnailContainerCss = (theme: SupersetTheme) => css`
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
width: ${theme.sizeUnit * THUMBNAIL_GRID_UNITS}px;
|
||||
position: relative;
|
||||
outline: none; /* Remove focus outline to show only selected state */
|
||||
|
||||
img {
|
||||
@@ -297,6 +299,16 @@ const thumbnailContainerCss = (theme: SupersetTheme) => css`
|
||||
.viztype-label {
|
||||
margin-top: ${theme.sizeUnit * 2}px;
|
||||
text-align: center;
|
||||
/* reserve a fixed two-line block so every tile is the same height,
|
||||
regardless of how long the chart name is. Longer names are clamped
|
||||
with an ellipsis; the full name stays available via the title tooltip. */
|
||||
line-height: ${theme.sizeUnit * 4}px;
|
||||
height: ${theme.sizeUnit * 8}px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -320,10 +332,19 @@ const HighlightLabel = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
// Wraps the thumbnail image so the "Featured" badge can be anchored to the
|
||||
// image itself rather than to the whole tile (whose height varies with the
|
||||
// chart-name length). line-height: 0 removes the inline-image descender gap.
|
||||
const ThumbnailImageWrapper = styled.div`
|
||||
position: relative;
|
||||
width: ${({ theme }) => theme.sizeUnit * THUMBNAIL_GRID_UNITS}px;
|
||||
line-height: 0;
|
||||
`;
|
||||
|
||||
const ThumbnailLabelWrapper = styled.div`
|
||||
position: absolute;
|
||||
right: ${({ theme }) => theme.sizeUnit}px;
|
||||
top: ${({ theme }) => theme.sizeUnit * 19}px;
|
||||
top: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
const TitleLabelWrapper = styled.div`
|
||||
@@ -367,27 +388,32 @@ const Thumbnail: FC<ThumbnailProps> = ({
|
||||
onFocus={handleFocus}
|
||||
data-test="viztype-selector-container"
|
||||
>
|
||||
<img
|
||||
alt={type.name}
|
||||
width="100%"
|
||||
className={`viztype-selector ${isSelected ? 'selected' : ''}`}
|
||||
src={
|
||||
isDarkMode && type.thumbnailDark ? type.thumbnailDark : type.thumbnail
|
||||
}
|
||||
/>
|
||||
<ThumbnailImageWrapper>
|
||||
<img
|
||||
alt={type.name}
|
||||
width="100%"
|
||||
className={`viztype-selector ${isSelected ? 'selected' : ''}`}
|
||||
src={
|
||||
isDarkMode && type.thumbnailDark
|
||||
? type.thumbnailDark
|
||||
: type.thumbnail
|
||||
}
|
||||
/>
|
||||
{type.label && (
|
||||
<ThumbnailLabelWrapper>
|
||||
<HighlightLabel>
|
||||
<div>{t(type.label)}</div>
|
||||
</HighlightLabel>
|
||||
</ThumbnailLabelWrapper>
|
||||
)}
|
||||
</ThumbnailImageWrapper>
|
||||
<div
|
||||
className="viztype-label"
|
||||
data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__viztype-label`}
|
||||
title={type.name}
|
||||
>
|
||||
{type.name}
|
||||
</div>
|
||||
{type.label && (
|
||||
<ThumbnailLabelWrapper>
|
||||
<HighlightLabel>
|
||||
<div>{t(type.label)}</div>
|
||||
</HighlightLabel>
|
||||
</ThumbnailLabelWrapper>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,6 +78,37 @@ import { useExploreDataExport } from './useExploreDataExport';
|
||||
|
||||
export const SEARCH_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* Escape a single CSV cell value.
|
||||
*
|
||||
* Mirrors the server-side chokepoint (superset/utils/csv.py escape_value):
|
||||
* values starting with a spreadsheet formula prefix (=, +, -, @, |, %, or a
|
||||
* leading tab/carriage return, optionally behind leading whitespace) are
|
||||
* neutralized with a leading single quote so exported cells cannot execute
|
||||
* as formulas when opened in Excel/LibreOffice/Google Sheets. Plain negative
|
||||
* numbers are left untouched. RFC-4180 quoting is applied afterwards.
|
||||
*/
|
||||
export const escapeCsvValue = (v: unknown): string => {
|
||||
if (v === null || v === undefined) return '';
|
||||
let s = String(v);
|
||||
if (s.length > 0) {
|
||||
const stripped = s.replace(/^\s+/, '');
|
||||
const startsLikeFormula =
|
||||
s[0] === '\t' ||
|
||||
s[0] === '\r' ||
|
||||
(stripped.length > 0 && '-@+|=%'.includes(stripped[0]));
|
||||
const isNegativeNumber = s.length > 1 && /^-[0-9.]+$/.test(s);
|
||||
if (startsLikeFormula && !isNegativeNumber) {
|
||||
// Escape pipe to be extra safe (DDE payloads), then prefix with a
|
||||
// single quote to prevent formula evaluation. Existing backslashes
|
||||
// must be escaped first so the resulting `\|`/`\\` sequences are
|
||||
// unambiguous to a downstream unescaper.
|
||||
s = `'${s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|')}`;
|
||||
}
|
||||
}
|
||||
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
|
||||
const MENU_KEYS = {
|
||||
EDIT_PROPERTIES: 'edit_properties',
|
||||
DASHBOARDS_ADDED_TO: 'dashboards_added_to',
|
||||
@@ -488,15 +519,11 @@ export const useExploreAdditionalActionsMenu = (
|
||||
filename: string,
|
||||
) => {
|
||||
if (!rows?.length || !columns?.length) return;
|
||||
const esc = (v: unknown): string => {
|
||||
if (v === null || v === undefined) return '';
|
||||
const s = String(v);
|
||||
const wrapped = /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
return wrapped;
|
||||
};
|
||||
const header = columns.map(c => esc(c.label ?? c.key ?? '')).join(',');
|
||||
const header = columns
|
||||
.map(c => escapeCsvValue(c.label ?? c.key ?? ''))
|
||||
.join(',');
|
||||
const body = rows
|
||||
.map(r => columns.map(c => esc(r[c.key])).join(','))
|
||||
.map(r => columns.map(c => escapeCsvValue(r[c.key])).join(','))
|
||||
.join('\n');
|
||||
const csv = `${header}\n${body}`;
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
|
||||
+40
@@ -25,6 +25,7 @@ import downloadAsPdf from 'src/utils/downloadAsPdf';
|
||||
import {
|
||||
useExploreAdditionalActionsMenu,
|
||||
getExportScreenshotMenuItems,
|
||||
escapeCsvValue,
|
||||
} from './index';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
import { Slice } from 'src/types/Chart';
|
||||
@@ -180,6 +181,45 @@ test('hides Edit chart properties from a chart editor lacking chart write permis
|
||||
expect(screen.queryByText('Edit chart properties')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('escapeCsvValue neutralizes spreadsheet formula prefixes', () => {
|
||||
// Mirrors superset/utils/csv.py escape_value so the client-built
|
||||
// "Current View" CSV cannot ship live formulas (CSV injection).
|
||||
expect(escapeCsvValue('=HYPERLINK("https://attacker.example")')).toBe(
|
||||
`"'=HYPERLINK(""https://attacker.example"")"`,
|
||||
);
|
||||
expect(escapeCsvValue('@SUM(1+1)')).toBe(`'@SUM(1+1)`);
|
||||
expect(escapeCsvValue('+cmd')).toBe(`'+cmd`);
|
||||
expect(escapeCsvValue('%x')).toBe(`'%x`);
|
||||
expect(escapeCsvValue('\t=1+1')).toBe(`'\t=1+1`);
|
||||
expect(escapeCsvValue(' =1+1')).toBe(`' =1+1`);
|
||||
expect(escapeCsvValue('=cmd|calc')).toBe(`'=cmd\\|calc`);
|
||||
});
|
||||
|
||||
test('escapeCsvValue escapes pre-existing backslashes before escaping pipes', () => {
|
||||
// A literal backslash sitting next to a pipe must not be left as-is: if it
|
||||
// were, the escaped output (`\|`) would be indistinguishable from an
|
||||
// escaped pipe, so a downstream unescaper couldn't recover the original
|
||||
// value. Escaping backslashes first keeps the two cases unambiguous.
|
||||
expect(escapeCsvValue('=cmd\\|calc')).toBe(`'=cmd\\\\\\|calc`);
|
||||
});
|
||||
|
||||
test('escapeCsvValue RFC-4180-quotes a value containing a bare carriage return', () => {
|
||||
// A raw \r inside a cell can be read as a record separator by some CSV
|
||||
// consumers, so it must trigger outer quoting the same way \n does, even
|
||||
// when it also triggered the formula-prefix guard above.
|
||||
expect(escapeCsvValue('\r=1+1')).toBe(`"'\r=1+1"`);
|
||||
});
|
||||
|
||||
test('escapeCsvValue keeps ordinary values intact', () => {
|
||||
expect(escapeCsvValue('regular text')).toBe('regular text');
|
||||
expect(escapeCsvValue('-12.5')).toBe('-12.5');
|
||||
expect(escapeCsvValue(42)).toBe('42');
|
||||
expect(escapeCsvValue(null)).toBe('');
|
||||
expect(escapeCsvValue(undefined)).toBe('');
|
||||
expect(escapeCsvValue('a,b')).toBe(`"a,b"`);
|
||||
expect(escapeCsvValue('say "hi"')).toBe(`"say ""hi"""`);
|
||||
});
|
||||
|
||||
test('shows 413 error toast when exportCSV fails with 413', async () => {
|
||||
mockExportChart.mockRejectedValue({ status: 413 });
|
||||
|
||||
|
||||
@@ -96,7 +96,11 @@ export default function ChartCard({
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const allowEdit = isUserEditorOrAdmin(user, chart.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
chart.editors,
|
||||
chart.extra_editors,
|
||||
);
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (canEdit) {
|
||||
|
||||
@@ -83,7 +83,11 @@ function DashboardCard({
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const allowEdit = isUserEditorOrAdmin(user, dashboard.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
dashboard.editors,
|
||||
dashboard.extra_editors,
|
||||
);
|
||||
const digest = dashboard.changed_on_utc || dashboard.changed_on;
|
||||
const thumbnailUrl =
|
||||
isFeatureEnabled(FeatureFlag.Thumbnails) && dashboard.id && digest
|
||||
|
||||
@@ -136,6 +136,7 @@ describe('DatabaseModal', () => {
|
||||
format: 'int32',
|
||||
maximum: 65536,
|
||||
minimum: 0,
|
||||
nullable: true,
|
||||
type: 'integer',
|
||||
},
|
||||
query: {
|
||||
@@ -153,7 +154,7 @@ describe('DatabaseModal', () => {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['database', 'host', 'port', 'username'],
|
||||
required: ['database', 'host', 'username'],
|
||||
type: 'object',
|
||||
},
|
||||
preferred: true,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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 {
|
||||
automaticNormalizationTransitions,
|
||||
isJsonValue,
|
||||
matchingAutomaticNormalizationTransitions,
|
||||
stashDropNormalizationTransitions,
|
||||
} from './normalization';
|
||||
|
||||
test('recognizes only values that JSON can represent faithfully', () => {
|
||||
expect(isJsonValue({ nested: [null, true, 3, 'value'] })).toBe(true);
|
||||
expect(isJsonValue(Number.NaN)).toBe(false);
|
||||
expect(isJsonValue(Number.POSITIVE_INFINITY)).toBe(false);
|
||||
expect(isJsonValue(new Date())).toBe(false);
|
||||
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(isJsonValue(cyclic)).toBe(false);
|
||||
});
|
||||
|
||||
test('records hydration changes only when input matches persisted data', () => {
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ row_limit: null },
|
||||
{ row_limit: null },
|
||||
{ row_limit: 10000, show_legend: true },
|
||||
),
|
||||
).toEqual({
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ row_limit: null },
|
||||
{ row_limit: 500 },
|
||||
{ row_limit: 10000 },
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not interpret a missing hydrated control as normalization', () => {
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ obsolete_control: true },
|
||||
{ obsolete_control: true },
|
||||
{},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('keeps only valid, unchanged transitions for a save', () => {
|
||||
const rowLimit = {
|
||||
control: 'row_limit',
|
||||
from_present: true as const,
|
||||
from_value: null,
|
||||
to_present: true as const,
|
||||
to_value: 10000,
|
||||
};
|
||||
const tracking = {
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: { show_legend: true as const },
|
||||
transitions: {
|
||||
row_limit: rowLimit,
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
matchingAutomaticNormalizationTransitions(tracking, {
|
||||
row_limit: 10000,
|
||||
show_legend: true,
|
||||
}),
|
||||
).toEqual({ row_limit: rowLimit });
|
||||
});
|
||||
|
||||
test('covers a stash-removed key still equal to its persisted value', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ order_desc: true, row_limit: 5000 },
|
||||
{ order_desc: true },
|
||||
{ row_limit: 5000 },
|
||||
),
|
||||
).toEqual({
|
||||
order_desc: {
|
||||
control: 'order_desc',
|
||||
from_present: true,
|
||||
from_value: true,
|
||||
to_present: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not cover a stashed value the user changed before it was hidden', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ server_page_length: 10 },
|
||||
{ server_page_length: 25 },
|
||||
{},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not cover stashed keys that were never persisted', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions({}, { totals_aggregate: 'SUM' }, {}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not cover keys the outgoing payload still carries', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ order_desc: true },
|
||||
{ order_desc: true },
|
||||
{ order_desc: true },
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('drop coverage requires a stash', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions({ order_desc: true }, undefined, {}),
|
||||
).toEqual({});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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 isEqual from 'lodash-es/isEqual';
|
||||
import type {
|
||||
AutomaticNormalizationTransition,
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
JsonValue,
|
||||
} from './types';
|
||||
|
||||
const isJsonValueInternal = (
|
||||
value: unknown,
|
||||
ancestors: WeakSet<object>,
|
||||
): value is JsonValue => {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value);
|
||||
}
|
||||
if (typeof value !== 'object' || ancestors.has(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
let isJsonCompatible: boolean;
|
||||
if (Array.isArray(value)) {
|
||||
isJsonCompatible = value.every(item =>
|
||||
isJsonValueInternal(item, ancestors),
|
||||
);
|
||||
} else {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
isJsonCompatible =
|
||||
(prototype === Object.prototype || prototype === null) &&
|
||||
Object.values(value).every(item => isJsonValueInternal(item, ancestors));
|
||||
}
|
||||
ancestors.delete(value);
|
||||
return isJsonCompatible;
|
||||
};
|
||||
|
||||
export const isJsonValue = (value: unknown): value is JsonValue =>
|
||||
isJsonValueInternal(value, new WeakSet());
|
||||
|
||||
/** Structural equality for JSON values, independent of object key order. */
|
||||
export const jsonValuesEqual = (left: unknown, right: unknown) =>
|
||||
isEqual(left, right);
|
||||
|
||||
interface NormalizationSnapshots {
|
||||
control: string;
|
||||
persisted: Record<string, unknown>;
|
||||
input: Record<string, unknown>;
|
||||
hydrated: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const automaticNormalizationTransition = ({
|
||||
control,
|
||||
persisted,
|
||||
input,
|
||||
hydrated,
|
||||
}: NormalizationSnapshots): AutomaticNormalizationTransition | undefined => {
|
||||
const fromPresent = Object.hasOwn(persisted, control);
|
||||
const inputPresent = Object.hasOwn(input, control);
|
||||
const toPresent = Object.hasOwn(hydrated, control);
|
||||
const fromValue = persisted[control];
|
||||
const inputValue = input[control];
|
||||
const toValue = hydrated[control];
|
||||
|
||||
const inputMatchesPersisted =
|
||||
fromPresent === inputPresent && jsonValuesEqual(fromValue, inputValue);
|
||||
const hydrationChangedValue =
|
||||
fromPresent !== toPresent || !jsonValuesEqual(fromValue, toValue);
|
||||
|
||||
// Disappearing keys (!toPresent) are deliberately not covered here:
|
||||
// hydration itself never removes keys from the merged snapshot. Machine
|
||||
// removals happen later, when StashFormDataContainer stashes invisible
|
||||
// controls out of form_data — those are covered at save time by
|
||||
// stashDropNormalizationTransitions, which uses the stash itself
|
||||
// (explore.hiddenFormData) as the proof the removal was not user-made.
|
||||
if (!inputMatchesPersisted || !toPresent || !hydrationChangedValue) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isJsonValue(toValue)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!fromPresent) {
|
||||
return {
|
||||
control,
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: toValue,
|
||||
};
|
||||
}
|
||||
if (!isJsonValue(fromValue)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
control,
|
||||
from_present: true,
|
||||
from_value: fromValue,
|
||||
to_present: true,
|
||||
to_value: toValue,
|
||||
};
|
||||
};
|
||||
|
||||
export const automaticNormalizationTransitions = (
|
||||
persisted: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
hydrated: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions => {
|
||||
const transitions: AutomaticNormalizationTransitions = {};
|
||||
const controls = new Set([...Object.keys(input), ...Object.keys(hydrated)]);
|
||||
controls.forEach(control => {
|
||||
const transition = automaticNormalizationTransition({
|
||||
control,
|
||||
persisted,
|
||||
input,
|
||||
hydrated,
|
||||
});
|
||||
if (transition) {
|
||||
transitions[control] = transition;
|
||||
}
|
||||
});
|
||||
return transitions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Advisory transitions for keys the stash removed from form_data.
|
||||
*
|
||||
* StashFormDataContainer moves an invisible control's value out of
|
||||
* ``form_data`` into ``explore.hiddenFormData``. That removal is
|
||||
* machine-made by construction, but it happens in render effects after
|
||||
* hydration, so hydration-time tracking cannot see it. This computes the
|
||||
* matching drop transitions at save time: a key counts only when the stash
|
||||
* holds it, the stashed value still equals the persisted value (a user edit
|
||||
* before hiding breaks the equality and stays recorded), and the outgoing
|
||||
* payload no longer carries the key. Keys absent from the stash — e.g.
|
||||
* removed by a viz-type switch — are never covered.
|
||||
*/
|
||||
export const stashDropNormalizationTransitions = (
|
||||
persisted: Record<string, unknown>,
|
||||
hiddenFormData: Record<string, unknown> | undefined,
|
||||
outgoingFormData: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions => {
|
||||
const transitions: AutomaticNormalizationTransitions = {};
|
||||
if (!hiddenFormData) {
|
||||
return transitions;
|
||||
}
|
||||
Object.keys(hiddenFormData).forEach(control => {
|
||||
if (!Object.hasOwn(persisted, control)) return;
|
||||
if (Object.hasOwn(outgoingFormData, control)) return;
|
||||
const fromValue = persisted[control];
|
||||
if (!isJsonValue(fromValue)) return;
|
||||
if (!jsonValuesEqual(hiddenFormData[control], fromValue)) return;
|
||||
transitions[control] = {
|
||||
control,
|
||||
from_present: true,
|
||||
from_value: fromValue,
|
||||
to_present: false,
|
||||
};
|
||||
});
|
||||
return transitions;
|
||||
};
|
||||
|
||||
export const matchingAutomaticNormalizationTransitions = (
|
||||
tracking: ChartNormalizationTrackingState | null | undefined,
|
||||
formData: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions =>
|
||||
Object.fromEntries(
|
||||
Object.entries(tracking?.transitions ?? {}).filter(
|
||||
([control, transition]) =>
|
||||
!tracking?.invalidatedControls[control] &&
|
||||
Object.hasOwn(formData, control) === transition.to_present &&
|
||||
(!transition.to_present ||
|
||||
jsonValuesEqual(formData[control], transition.to_value)),
|
||||
),
|
||||
);
|
||||
@@ -18,10 +18,14 @@
|
||||
*/
|
||||
import versionHistoryReducer, {
|
||||
appendVersionSessionLog,
|
||||
beginChartNormalizationSave,
|
||||
clearVersionPreview,
|
||||
clearVersionSessionLog,
|
||||
completeChartNormalizationSave,
|
||||
closeVersionHistoryPanel,
|
||||
openVersionHistoryPanel,
|
||||
hydrateChartNormalization,
|
||||
invalidateChartNormalizationControls,
|
||||
selectIsChartVersionPreviewActive,
|
||||
selectIsDashboardVersionPreviewActive,
|
||||
selectVersionHistory,
|
||||
@@ -145,3 +149,55 @@ test('per-entity preview selectors only match their own entity type', () => {
|
||||
expect(selectIsChartVersionPreviewActive(state)).toBe(true);
|
||||
expect(selectIsDashboardVersionPreviewActive(state)).toBe(false);
|
||||
});
|
||||
|
||||
test('normalization tracking invalidates controls without re-adding transitions', () => {
|
||||
let state = versionHistoryReducer(
|
||||
initial,
|
||||
hydrateChartNormalization({
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'session-a',
|
||||
transitions: {
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
},
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
state = versionHistoryReducer(
|
||||
state,
|
||||
invalidateChartNormalizationControls(['row_limit']),
|
||||
);
|
||||
expect(state.chartNormalization?.invalidatedControls).toEqual({
|
||||
row_limit: true,
|
||||
});
|
||||
expect(state.chartNormalization?.transitions.row_limit).toBeDefined();
|
||||
});
|
||||
|
||||
test('late save completion cannot rebase another hydration session', () => {
|
||||
let state = versionHistoryReducer(
|
||||
initial,
|
||||
hydrateChartNormalization({
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'session-b',
|
||||
transitions: {},
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
state = versionHistoryReducer(
|
||||
state,
|
||||
beginChartNormalizationSave(7, 'session-b', 'attempt-b'),
|
||||
);
|
||||
const unchanged = versionHistoryReducer(
|
||||
state,
|
||||
completeChartNormalizationSave(7, 'session-a', 'attempt-a', {}),
|
||||
);
|
||||
expect(unchanged).toBe(state);
|
||||
expect(unchanged.chartNormalization?.saveAttemptId).toBe('attempt-b');
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
import type {
|
||||
ActivityInclude,
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
SessionLogEntry,
|
||||
VersionedEntityType,
|
||||
VersionHistoryState,
|
||||
@@ -33,6 +35,12 @@ export const VERSION_PREVIEW_APPLIED = 'VERSION_PREVIEW_APPLIED';
|
||||
export const VERSION_RESTORED = 'VERSION_RESTORED';
|
||||
export const APPEND_VERSION_SESSION_LOG = 'APPEND_VERSION_SESSION_LOG';
|
||||
export const CLEAR_VERSION_SESSION_LOG = 'CLEAR_VERSION_SESSION_LOG';
|
||||
export const HYDRATE_CHART_NORMALIZATION = 'HYDRATE_CHART_NORMALIZATION';
|
||||
export const INVALIDATE_CHART_NORMALIZATION_CONTROLS =
|
||||
'INVALIDATE_CHART_NORMALIZATION_CONTROLS';
|
||||
export const BEGIN_CHART_NORMALIZATION_SAVE = 'BEGIN_CHART_NORMALIZATION_SAVE';
|
||||
export const COMPLETE_CHART_NORMALIZATION_SAVE =
|
||||
'COMPLETE_CHART_NORMALIZATION_SAVE';
|
||||
|
||||
/** Upper bound on retained unsaved-edit entries; older ones drop off. */
|
||||
export const MAX_SESSION_LOG_ENTRIES = 50;
|
||||
@@ -86,6 +94,31 @@ interface ClearSessionLogAction {
|
||||
type: typeof CLEAR_VERSION_SESSION_LOG;
|
||||
}
|
||||
|
||||
interface HydrateChartNormalizationAction {
|
||||
type: typeof HYDRATE_CHART_NORMALIZATION;
|
||||
tracking: ChartNormalizationTrackingState;
|
||||
}
|
||||
|
||||
interface InvalidateChartNormalizationControlsAction {
|
||||
type: typeof INVALIDATE_CHART_NORMALIZATION_CONTROLS;
|
||||
controls: string[];
|
||||
}
|
||||
|
||||
interface BeginChartNormalizationSaveAction {
|
||||
type: typeof BEGIN_CHART_NORMALIZATION_SAVE;
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
saveAttemptId: string;
|
||||
}
|
||||
|
||||
interface CompleteChartNormalizationSaveAction {
|
||||
type: typeof COMPLETE_CHART_NORMALIZATION_SAVE;
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
saveAttemptId: string;
|
||||
transitions: AutomaticNormalizationTransitions;
|
||||
}
|
||||
|
||||
export type VersionHistoryAction =
|
||||
| OpenPanelAction
|
||||
| ClosePanelAction
|
||||
@@ -95,7 +128,11 @@ export type VersionHistoryAction =
|
||||
| PreviewAppliedAction
|
||||
| VersionRestoredAction
|
||||
| AppendSessionLogAction
|
||||
| ClearSessionLogAction;
|
||||
| ClearSessionLogAction
|
||||
| HydrateChartNormalizationAction
|
||||
| InvalidateChartNormalizationControlsAction
|
||||
| BeginChartNormalizationSaveAction
|
||||
| CompleteChartNormalizationSaveAction;
|
||||
|
||||
export const openVersionHistoryPanel = (
|
||||
entityType: VersionedEntityType,
|
||||
@@ -161,6 +198,44 @@ export const clearVersionSessionLog = (): ClearSessionLogAction => ({
|
||||
type: CLEAR_VERSION_SESSION_LOG,
|
||||
});
|
||||
|
||||
export const hydrateChartNormalization = (
|
||||
tracking: ChartNormalizationTrackingState,
|
||||
): HydrateChartNormalizationAction => ({
|
||||
type: HYDRATE_CHART_NORMALIZATION,
|
||||
tracking,
|
||||
});
|
||||
|
||||
export const invalidateChartNormalizationControls = (
|
||||
controls: string[],
|
||||
): InvalidateChartNormalizationControlsAction => ({
|
||||
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
controls,
|
||||
});
|
||||
|
||||
export const beginChartNormalizationSave = (
|
||||
chartId: number,
|
||||
hydrationSessionId: string,
|
||||
saveAttemptId: string,
|
||||
): BeginChartNormalizationSaveAction => ({
|
||||
type: BEGIN_CHART_NORMALIZATION_SAVE,
|
||||
chartId,
|
||||
hydrationSessionId,
|
||||
saveAttemptId,
|
||||
});
|
||||
|
||||
export const completeChartNormalizationSave = (
|
||||
chartId: number,
|
||||
hydrationSessionId: string,
|
||||
saveAttemptId: string,
|
||||
transitions: AutomaticNormalizationTransitions,
|
||||
): CompleteChartNormalizationSaveAction => ({
|
||||
type: COMPLETE_CHART_NORMALIZATION_SAVE,
|
||||
chartId,
|
||||
hydrationSessionId,
|
||||
saveAttemptId,
|
||||
transitions,
|
||||
});
|
||||
|
||||
const initialState: VersionHistoryState = {
|
||||
isPanelOpen: false,
|
||||
entityType: null,
|
||||
@@ -170,6 +245,7 @@ const initialState: VersionHistoryState = {
|
||||
sessionLog: [],
|
||||
restoreCount: 0,
|
||||
lastRestoredEntityUuid: null,
|
||||
chartNormalization: null,
|
||||
};
|
||||
|
||||
export default function versionHistoryReducer(
|
||||
@@ -240,6 +316,58 @@ export default function versionHistoryReducer(
|
||||
}
|
||||
case CLEAR_VERSION_SESSION_LOG:
|
||||
return { ...state, sessionLog: [] };
|
||||
case HYDRATE_CHART_NORMALIZATION:
|
||||
return { ...state, chartNormalization: action.tracking };
|
||||
case INVALIDATE_CHART_NORMALIZATION_CONTROLS: {
|
||||
if (!state.chartNormalization || action.controls.length === 0) {
|
||||
return state;
|
||||
}
|
||||
const invalidatedControls = {
|
||||
...state.chartNormalization.invalidatedControls,
|
||||
};
|
||||
action.controls.forEach(control => {
|
||||
invalidatedControls[control] = true;
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
invalidatedControls,
|
||||
},
|
||||
};
|
||||
}
|
||||
case BEGIN_CHART_NORMALIZATION_SAVE:
|
||||
if (
|
||||
state.chartNormalization?.chartId !== action.chartId ||
|
||||
state.chartNormalization.hydrationSessionId !==
|
||||
action.hydrationSessionId
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
saveAttemptId: action.saveAttemptId,
|
||||
},
|
||||
};
|
||||
case COMPLETE_CHART_NORMALIZATION_SAVE:
|
||||
if (
|
||||
state.chartNormalization?.chartId !== action.chartId ||
|
||||
state.chartNormalization.hydrationSessionId !==
|
||||
action.hydrationSessionId ||
|
||||
state.chartNormalization.saveAttemptId !== action.saveAttemptId
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
transitions: action.transitions,
|
||||
saveAttemptId: null,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -292,3 +420,6 @@ export const selectVersionLastRestoredUuid = (state: VersionHistoryRootState) =>
|
||||
|
||||
export const selectVersionSessionLog = (state: VersionHistoryRootState) =>
|
||||
selectVersionHistory(state).sessionLog;
|
||||
|
||||
export const selectChartNormalization = (state: VersionHistoryRootState) =>
|
||||
selectVersionHistory(state).chartNormalization;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { versionSessionLogMiddleware } from './sessionLogMiddleware';
|
||||
import {
|
||||
APPEND_VERSION_SESSION_LOG,
|
||||
CLEAR_VERSION_SESSION_LOG,
|
||||
INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
} from './reducer';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -77,7 +78,7 @@ test('falls back to a humanized control name when no label exists', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('skips programmatic control writes so untouched charts stay clean', async () => {
|
||||
test('programmatic writes invalidate normalization without logging an edit', async () => {
|
||||
// Effects rewrite controls with no user gesture (transferred-control
|
||||
// cleanup after load, derived margins); logging them would report unsaved
|
||||
// edits the user never made. Built with the REAL action creator so the
|
||||
@@ -89,15 +90,54 @@ test('skips programmatic control writes so untouched charts stay clean', async (
|
||||
explore: { controls: { metrics: { label: 'Metrics' } } },
|
||||
});
|
||||
run(store, setControlValue('metrics', [], undefined, { programmatic: true }));
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.dispatch).toHaveBeenCalledWith({
|
||||
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
controls: ['metrics'],
|
||||
});
|
||||
|
||||
// The same creator without the mark still logs.
|
||||
store.dispatch.mockClear();
|
||||
run(store, setControlValue('metrics', []));
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: APPEND_VERSION_SESSION_LOG }),
|
||||
);
|
||||
});
|
||||
|
||||
test('a write matching the hydrated default preserves normalization', async () => {
|
||||
const { setControlValue } =
|
||||
await import('src/explore/actions/exploreActions');
|
||||
const store = buildStore({
|
||||
explore: {
|
||||
controls: { show_totals: { label: 'Show totals' } },
|
||||
form_data: { show_totals: false },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {
|
||||
show_totals: {
|
||||
control: 'show_totals',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
run(
|
||||
store,
|
||||
setControlValue('show_totals', false, undefined, { programmatic: true }),
|
||||
);
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clears the session log when the explore page hydrates', () => {
|
||||
const store = buildStore();
|
||||
run(store, { type: 'HYDRATE_EXPLORE', data: {} });
|
||||
@@ -188,7 +228,7 @@ test('a history step cannot collapse into an adjacent control entry', async () =
|
||||
await import('src/explore/actions/exploreActions');
|
||||
const store = buildStore({ explore: { controls: {} } });
|
||||
run(store, setExploreControls({} as never));
|
||||
const { entry } = store.dispatch.mock.calls[0][0];
|
||||
const [[{ entry }]] = store.dispatch.mock.calls;
|
||||
expect(entry.controlName).not.toMatch(/^[a-z]/);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
import type { Middleware } from 'redux';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { appendVersionSessionLog, clearVersionSessionLog } from './reducer';
|
||||
import {
|
||||
appendVersionSessionLog,
|
||||
clearVersionSessionLog,
|
||||
invalidateChartNormalizationControls,
|
||||
} from './reducer';
|
||||
import { jsonValuesEqual } from './normalization';
|
||||
import type { ChartNormalizationTrackingState } from './types';
|
||||
|
||||
// Action types are inlined (rather than imported from the explore
|
||||
// module) so this middleware does not pull explore code into every
|
||||
@@ -47,9 +53,54 @@ interface SessionLogState {
|
||||
user?: { firstName?: string; lastName?: string };
|
||||
explore?: {
|
||||
controls?: Record<string, { label?: unknown } | undefined>;
|
||||
form_data?: Record<string, unknown>;
|
||||
};
|
||||
versionHistory?: {
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Untrusted Explore action shape; fields narrow only at this boundary. */
|
||||
interface ExploreBoundaryAction {
|
||||
type: unknown;
|
||||
controlName?: unknown;
|
||||
formData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const changedFormDataKeys = (
|
||||
before: Record<string, unknown> = {},
|
||||
after: Record<string, unknown> = {},
|
||||
) =>
|
||||
[...new Set([...Object.keys(before), ...Object.keys(after)])].filter(
|
||||
key => before[key] !== after[key],
|
||||
);
|
||||
|
||||
/**
|
||||
* Anti-corruption adapter from Explore's action vocabulary to the stable
|
||||
* versioning concept of controls whose user-intent evidence is no longer valid.
|
||||
*/
|
||||
export const normalizationControlsChangedByExplore = (
|
||||
action: ExploreBoundaryAction,
|
||||
before: Record<string, unknown> | undefined,
|
||||
after: Record<string, unknown> | undefined,
|
||||
) => {
|
||||
if (action.type === HYDRATE_EXPLORE) {
|
||||
return [];
|
||||
}
|
||||
const controls = changedFormDataKeys(before, after);
|
||||
if (
|
||||
action.type === SET_FIELD_VALUE &&
|
||||
typeof action.controlName === 'string'
|
||||
) {
|
||||
controls.push(action.controlName);
|
||||
} else if (action.type === SET_EXPLORE_CONTROLS && action.formData) {
|
||||
controls.push(...Object.keys(action.formData));
|
||||
} else if (action.type === UPDATE_FORM_DATA_BY_DATASOURCE) {
|
||||
controls.push(DATASOURCE_CONTROL_NAME);
|
||||
}
|
||||
return [...new Set(controls)];
|
||||
};
|
||||
|
||||
function controlLabel(state: SessionLogState, controlName: string): string {
|
||||
const label = state.explore?.controls?.[controlName]?.label;
|
||||
return typeof label === 'string' && label
|
||||
@@ -64,6 +115,25 @@ function userName(state: SessionLogState): string | null {
|
||||
return name || null;
|
||||
}
|
||||
|
||||
const normalizationControlsNoLongerMatching = (
|
||||
controls: string[],
|
||||
state: SessionLogState,
|
||||
) => {
|
||||
const formData = state.explore?.form_data ?? {};
|
||||
const transitions = state.versionHistory?.chartNormalization?.transitions;
|
||||
return controls.filter(control => {
|
||||
const transition = transitions?.[control];
|
||||
if (!transition) {
|
||||
return true;
|
||||
}
|
||||
const present = Object.hasOwn(formData, control);
|
||||
return (
|
||||
present !== transition.to_present ||
|
||||
(present && !jsonValuesEqual(formData[control], transition.to_value))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Records unsaved explore control changes in the version history
|
||||
* session log ("Current version" section) and resets the log whenever
|
||||
@@ -71,6 +141,7 @@ function userName(state: SessionLogState): string | null {
|
||||
*/
|
||||
export const versionSessionLogMiddleware: Middleware =
|
||||
store => next => action => {
|
||||
const before = (store.getState() as SessionLogState).explore?.form_data;
|
||||
const result = next(action);
|
||||
if (!isFeatureEnabled(FeatureFlag.VersionHistory)) {
|
||||
return result;
|
||||
@@ -151,5 +222,17 @@ export const versionSessionLogMiddleware: Middleware =
|
||||
}),
|
||||
);
|
||||
}
|
||||
const state = store.getState() as SessionLogState;
|
||||
const changedControls = normalizationControlsNoLongerMatching(
|
||||
normalizationControlsChangedByExplore(
|
||||
action,
|
||||
before,
|
||||
state.explore?.form_data,
|
||||
),
|
||||
state,
|
||||
);
|
||||
if (changedControls.length) {
|
||||
store.dispatch(invalidateChartNormalizationControls(changedControls));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -209,6 +209,45 @@ export interface SessionLogEntry {
|
||||
user: string | null;
|
||||
}
|
||||
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
type PresentNormalizationValue<Prefix extends 'from' | 'to'> =
|
||||
Prefix extends 'from'
|
||||
? { from_present: true; from_value: JsonValue }
|
||||
: { to_present: true; to_value: JsonValue };
|
||||
|
||||
type MissingNormalizationValue<Prefix extends 'from' | 'to'> =
|
||||
Prefix extends 'from'
|
||||
? { from_present: false; from_value?: never }
|
||||
: { to_present: false; to_value?: never };
|
||||
|
||||
/** One guarded hydration transition sent with an existing-chart overwrite. */
|
||||
export type AutomaticNormalizationTransition = { control: string } & (
|
||||
| PresentNormalizationValue<'from'>
|
||||
| MissingNormalizationValue<'from'>
|
||||
) &
|
||||
(PresentNormalizationValue<'to'> | MissingNormalizationValue<'to'>);
|
||||
|
||||
export type AutomaticNormalizationTransitions = Record<
|
||||
string,
|
||||
AutomaticNormalizationTransition
|
||||
>;
|
||||
|
||||
/** Identity-bound state for one chart hydration and its in-flight save. */
|
||||
export interface ChartNormalizationTrackingState {
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
transitions: AutomaticNormalizationTransitions;
|
||||
invalidatedControls: Record<string, true>;
|
||||
saveAttemptId: string | null;
|
||||
}
|
||||
|
||||
export interface VersionHistoryState {
|
||||
isPanelOpen: boolean;
|
||||
entityType: VersionedEntityType | null;
|
||||
@@ -228,4 +267,6 @@ export interface VersionHistoryState {
|
||||
* the one their page shows.
|
||||
*/
|
||||
lastRestoredEntityUuid: string | null;
|
||||
/** Advisory transitions for the active Explore chart hydration. */
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
}
|
||||
|
||||
@@ -383,3 +383,223 @@ describe('ChartList - Global Filter Interactions', () => {
|
||||
expect((searchInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// The blocking-alerts/reports pre-flight in the Archive modal (sc-117151).
|
||||
// Each test registers its report-API route BEFORE setupMocks so it takes
|
||||
// precedence over the catch-all route.
|
||||
const adminChartUser = { ...mockUser, username: 'admin', permissions: {} };
|
||||
|
||||
const openFirstDeleteModal = async () => {
|
||||
// ALERT_REPORTS must be on for the pre-flight to fire at all — with it off
|
||||
// the modal opens synchronously with no dependency fetch (see the flag-off
|
||||
// test below).
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockImplementation(
|
||||
(feature: string) =>
|
||||
feature === 'SOFT_DELETE' || feature === 'ALERT_REPORTS',
|
||||
);
|
||||
renderChartList(adminChartUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
const deleteButtons = await screen.findAllByTestId('chart-row-delete');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
return screen.findByRole('dialog');
|
||||
};
|
||||
|
||||
test('archive modal lists the blocking alerts and reports with their types', async () => {
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get('glob:*/api/v1/report/*', {
|
||||
count: 2,
|
||||
result: [
|
||||
{ id: 1, name: 'TC-081 rerun report', type: 'Report' },
|
||||
{ id: 2, name: 'Threshold alert', type: 'Alert' },
|
||||
],
|
||||
});
|
||||
setupMocks();
|
||||
try {
|
||||
const dialog = await openFirstDeleteModal();
|
||||
expect(
|
||||
within(dialog).getByText('Associated alerts and reports'),
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('TC-081 rerun report')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Threshold alert')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Report')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Alert')).toBeInTheDocument();
|
||||
// Advisory only: the Archive button stays enabled.
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: 'Archive' }),
|
||||
).toBeEnabled();
|
||||
} finally {
|
||||
fetchMock.clearHistory();
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
test('archive modal is unchanged when the chart has no alerts or reports', async () => {
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get('glob:*/api/v1/report/*', { count: 0, result: [] });
|
||||
setupMocks();
|
||||
try {
|
||||
const dialog = await openFirstDeleteModal();
|
||||
expect(
|
||||
within(dialog).getByText(/moved to Recently Archived/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).queryByText('Associated alerts and reports'),
|
||||
).not.toBeInTheDocument();
|
||||
} finally {
|
||||
fetchMock.clearHistory();
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
test('archive modal opens unchanged and confirm still deletes when the report API 404s', async () => {
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get('glob:*/api/v1/report/*', 404);
|
||||
fetchMock.delete(`glob:*/api/v1/chart/${mockCharts[0].id}`, {});
|
||||
setupMocks();
|
||||
try {
|
||||
const dialog = await openFirstDeleteModal();
|
||||
expect(
|
||||
within(dialog).queryByText('Associated alerts and reports'),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Archive' }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.callHistory.calls(`glob:*/api/v1/chart/${mockCharts[0].id}`),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
} finally {
|
||||
fetchMock.clearHistory();
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
test('archive modal caps the list at ten and reports the overflow count', async () => {
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get('glob:*/api/v1/report/*', {
|
||||
count: 12,
|
||||
result: Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `Blocking report ${i + 1}`,
|
||||
type: 'Report',
|
||||
})),
|
||||
});
|
||||
setupMocks();
|
||||
try {
|
||||
const dialog = await openFirstDeleteModal();
|
||||
expect(within(dialog).getByText('Blocking report 10')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('... and 2 more')).toBeInTheDocument();
|
||||
} finally {
|
||||
fetchMock.clearHistory();
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
test('archive modal refetches on every open so the list stays fresh', async () => {
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get(
|
||||
'glob:*/api/v1/report/*',
|
||||
{
|
||||
count: 1,
|
||||
result: [{ id: 1, name: 'Detach me first', type: 'Report' }],
|
||||
},
|
||||
{ name: 'blocking-reports' },
|
||||
);
|
||||
setupMocks();
|
||||
try {
|
||||
const dialog = await openFirstDeleteModal();
|
||||
expect(within(dialog).getByText('Detach me first')).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// The user detaches the report; the next open must show the new truth.
|
||||
fetchMock.removeRoute('blocking-reports');
|
||||
fetchMock.get(
|
||||
'glob:*/api/v1/report/*',
|
||||
{ count: 0, result: [] },
|
||||
{ name: 'blocking-reports-empty' },
|
||||
);
|
||||
const deleteButtons = await screen.findAllByTestId('chart-row-delete');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
const reopened = await screen.findByRole('dialog');
|
||||
expect(
|
||||
within(reopened).queryByText('Detach me first'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(reopened).queryByText('Associated alerts and reports'),
|
||||
).not.toBeInTheDocument();
|
||||
} finally {
|
||||
fetchMock.clearHistory();
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
test('archive modal opens without any report fetch when ALERT_REPORTS is off', async () => {
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get(
|
||||
'glob:*/api/v1/report/*',
|
||||
{
|
||||
count: 1,
|
||||
result: [{ id: 1, name: 'Should not appear', type: 'Report' }],
|
||||
},
|
||||
{ name: 'reports-should-not-be-called' },
|
||||
);
|
||||
setupMocks();
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockImplementation((feature: string) => feature === 'SOFT_DELETE');
|
||||
try {
|
||||
renderChartList(adminChartUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
const deleteButtons = await screen.findAllByTestId('chart-row-delete');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(
|
||||
within(dialog).queryByText('Associated alerts and reports'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
fetchMock.callHistory.calls('reports-should-not-be-called'),
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
fetchMock.clearHistory();
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
test('delete confirmation keeps the type-DELETE gate when SOFT_DELETE is off', async () => {
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get('glob:*/api/v1/report/*', { count: 0, result: [] });
|
||||
setupMocks();
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockImplementation((feature: string) => feature === 'ALERT_REPORTS');
|
||||
try {
|
||||
renderChartList(adminChartUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
const deleteButtons = await screen.findAllByTestId('chart-row-delete');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText('Please confirm')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('delete-modal-input')).toBeInTheDocument();
|
||||
} finally {
|
||||
fetchMock.clearHistory();
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetTheme, css, styled } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { t, tn } from '@apache-superset/core/translation';
|
||||
import {
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
SupersetClient,
|
||||
isMatrixifyEnabled,
|
||||
} from '@superset-ui/core';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import rison from 'rison';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
ActionButton,
|
||||
ConfirmStatusChange,
|
||||
CertifiedBadge,
|
||||
DeleteModal,
|
||||
List,
|
||||
Tooltip,
|
||||
FaveStar,
|
||||
InfoTooltip,
|
||||
@@ -111,6 +113,77 @@ const FlexRowContainer = styled.div`
|
||||
`;
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
// How many blocking alerts/reports the archive modal previews before the
|
||||
// "... and N more" overflow line (dataset-modal parity).
|
||||
const BLOCKING_REPORTS_PREVIEW_SIZE = 10;
|
||||
|
||||
interface BlockingReport {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'Alert' | 'Report';
|
||||
}
|
||||
|
||||
interface ChartDeleteState {
|
||||
chart: Chart;
|
||||
blockingReports: BlockingReport[];
|
||||
blockingReportsCount: number;
|
||||
}
|
||||
|
||||
function ChartArchiveDescription({
|
||||
chart,
|
||||
blockingReports,
|
||||
blockingReportsCount,
|
||||
softDelete,
|
||||
}: ChartDeleteState & { softDelete: boolean }) {
|
||||
const overflowCount = blockingReportsCount - blockingReports.length;
|
||||
return (
|
||||
<>
|
||||
{softDelete ? (
|
||||
<p>{archiveConfirmDescription(t('chart'))}</p>
|
||||
) : (
|
||||
<p>
|
||||
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>?
|
||||
</p>
|
||||
)}
|
||||
{blockingReports.length > 0 && (
|
||||
<>
|
||||
<h4>{t('Associated alerts and reports')}</h4>
|
||||
<p>
|
||||
{t(
|
||||
'Archiving or deleting this chart will be blocked while the following alerts or reports use it. Detach or delete them first.',
|
||||
)}
|
||||
</p>
|
||||
<List
|
||||
split={false}
|
||||
size="small"
|
||||
dataSource={blockingReports}
|
||||
renderItem={(report: BlockingReport) => (
|
||||
<List.Item key={report.id} compact>
|
||||
<List.Item.Meta
|
||||
avatar={<span aria-hidden="true">•</span>}
|
||||
title={report.name}
|
||||
description={
|
||||
report.type === 'Alert' ? t('Alert') : t('Report')
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
{overflowCount > 0 && (
|
||||
<p>
|
||||
{tn(
|
||||
'... and %s more',
|
||||
'... and %s more',
|
||||
overflowCount,
|
||||
overflowCount,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const PASSWORDS_NEEDED_MESSAGE = t(
|
||||
'The passwords for the databases below are needed in order to ' +
|
||||
'import them together with the charts. Please note that the ' +
|
||||
@@ -211,6 +284,11 @@ function ChartList(props: ChartListProps) {
|
||||
} = useChartEditModal(setCharts, charts);
|
||||
|
||||
const [importingChart, showImportModal] = useState<boolean>(false);
|
||||
const [chartCurrentlyDeleting, setChartCurrentlyDeleting] =
|
||||
useState<ChartDeleteState | null>(null);
|
||||
// Monotonic token: a late pre-flight response for an earlier click must not
|
||||
// swap the modal to a different chart (last-response-wins race).
|
||||
const deleteModalRequestRef = useRef(0);
|
||||
const [passwordFields, setPasswordFields] = useState<string[]>([]);
|
||||
const [preparingExport, setPreparingExport] = useState<boolean>(false);
|
||||
const [sshTunnelPasswordFields, setSSHTunnelPasswordFields] = useState<
|
||||
@@ -274,6 +352,51 @@ function ChartList(props: ChartListProps) {
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const openChartDeleteModal = useCallback((chart: Chart) => {
|
||||
deleteModalRequestRef.current += 1;
|
||||
const requestToken = deleteModalRequestRef.current;
|
||||
if (!isFeatureEnabled(FeatureFlag.AlertReports)) {
|
||||
// The whole report API 404s when ALERT_REPORTS is off, while the delete
|
||||
// guard still fires server-side. Skip the doomed request and open the
|
||||
// unchanged modal immediately.
|
||||
setChartCurrentlyDeleting({
|
||||
chart,
|
||||
blockingReports: [],
|
||||
blockingReportsCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const queryParams = rison.encode({
|
||||
filters: [{ col: 'chart_id', opr: 'eq', value: chart.id }],
|
||||
columns: ['id', 'name', 'type'],
|
||||
order_column: 'name',
|
||||
order_direction: 'asc',
|
||||
page_size: BLOCKING_REPORTS_PREVIEW_SIZE,
|
||||
});
|
||||
SupersetClient.get({ endpoint: `/api/v1/report/?q=${queryParams}` })
|
||||
.then(({ json = {} }) => {
|
||||
if (requestToken !== deleteModalRequestRef.current) return;
|
||||
const blockingReports: BlockingReport[] = json.result ?? [];
|
||||
setChartCurrentlyDeleting({
|
||||
chart,
|
||||
blockingReports,
|
||||
blockingReportsCount: json.count ?? blockingReports.length,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestToken !== deleteModalRequestRef.current) return;
|
||||
// The report API can be visibility-filtered below what the delete
|
||||
// guard sees, or fail outright. The list is advisory only, so every
|
||||
// failure opens the unchanged modal rather than blocking the action;
|
||||
// the confirm-time guard stays authoritative.
|
||||
setChartCurrentlyDeleting({
|
||||
chart,
|
||||
blockingReports: [],
|
||||
blockingReportsCount: 0,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
function handleBulkChartDelete(chartsToDelete: Chart[]) {
|
||||
SupersetClient.delete({
|
||||
endpoint: `/api/v1/chart/?q=${rison.encode(
|
||||
@@ -527,14 +650,11 @@ function ChartList(props: ChartListProps) {
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: CellProps<Chart>) => {
|
||||
const allowEdit = isUserEditorOrAdmin(user, original.editors);
|
||||
const handleDelete = () =>
|
||||
handleChartDelete(
|
||||
original,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
refreshData,
|
||||
);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
original.editors,
|
||||
original.extra_editors,
|
||||
);
|
||||
const openEditModal = () => openChartEditModal(original);
|
||||
const handleExport = () => handleBulkChartExport([original]);
|
||||
if (!canEdit && !canDelete && !canExport) {
|
||||
@@ -573,43 +693,21 @@ function ChartList(props: ChartListProps) {
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ConfirmStatusChange
|
||||
recoverable={softDelete}
|
||||
title={
|
||||
softDelete
|
||||
? t('Archive %(name)s?', { name: original.slice_name })
|
||||
: t('Please confirm')
|
||||
<ActionButton
|
||||
label={deleteActionLabel()}
|
||||
tooltip={
|
||||
allowEdit
|
||||
? deleteActionLabel()
|
||||
: t(
|
||||
'You must be a chart editor in order to delete. Please reach out to a chart editor to request modifications or edit access.',
|
||||
)
|
||||
}
|
||||
description={
|
||||
softDelete ? (
|
||||
archiveConfirmDescription(t('chart'))
|
||||
) : (
|
||||
<>
|
||||
{t('Are you sure you want to delete')}{' '}
|
||||
<b>{original.slice_name}</b>?
|
||||
</>
|
||||
)
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
{confirmDelete => (
|
||||
<ActionButton
|
||||
label={deleteActionLabel()}
|
||||
tooltip={
|
||||
allowEdit
|
||||
? deleteActionLabel()
|
||||
: t(
|
||||
'You must be a chart editor in order to delete. Please reach out to a chart editor to request modifications or edit access.',
|
||||
)
|
||||
}
|
||||
placement="bottom"
|
||||
icon={<Icons.DeleteOutlined iconSize="l" />}
|
||||
dataTest="chart-row-delete"
|
||||
disabled={!allowEdit}
|
||||
onClick={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
</ConfirmStatusChange>
|
||||
placement="bottom"
|
||||
icon={<Icons.DeleteOutlined iconSize="l" />}
|
||||
dataTest="chart-row-delete"
|
||||
disabled={!allowEdit}
|
||||
onClick={() => openChartDeleteModal(original)}
|
||||
/>
|
||||
)}
|
||||
</Actions>
|
||||
);
|
||||
@@ -634,11 +732,9 @@ function ChartList(props: ChartListProps) {
|
||||
canExport,
|
||||
saveFavoriteStatus,
|
||||
favoriteStatus,
|
||||
refreshData,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
handleBulkChartExport,
|
||||
openChartEditModal,
|
||||
openChartDeleteModal,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -933,6 +1029,36 @@ function ChartList(props: ChartListProps) {
|
||||
slice={sliceCurrentlyEditing}
|
||||
/>
|
||||
)}
|
||||
{chartCurrentlyDeleting && (
|
||||
<DeleteModal
|
||||
recoverable={softDelete}
|
||||
title={
|
||||
softDelete
|
||||
? t('Archive %(name)s?', {
|
||||
name: chartCurrentlyDeleting.chart.slice_name,
|
||||
})
|
||||
: t('Please confirm')
|
||||
}
|
||||
name={chartCurrentlyDeleting.chart.slice_name}
|
||||
open
|
||||
description={
|
||||
<ChartArchiveDescription
|
||||
{...chartCurrentlyDeleting}
|
||||
softDelete={softDelete}
|
||||
/>
|
||||
}
|
||||
onConfirm={() => {
|
||||
handleChartDelete(
|
||||
chartCurrentlyDeleting.chart,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
refreshData,
|
||||
);
|
||||
setChartCurrentlyDeleting(null);
|
||||
}}
|
||||
onHide={() => setChartCurrentlyDeleting(null)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmStatusChange
|
||||
recoverable={softDelete}
|
||||
title={softDelete ? t('Archive selected charts?') : t('Please confirm')}
|
||||
|
||||
@@ -122,6 +122,8 @@ export interface Dashboard {
|
||||
description?: string;
|
||||
thumbnail_url?: string | null;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
tags: TagType[];
|
||||
created_by: object;
|
||||
@@ -505,7 +507,11 @@ function DashboardList(props: DashboardListProps) {
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: CellProps<Dashboard>) => {
|
||||
const allowEdit = isUserEditorOrAdmin(user, original.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
original.editors,
|
||||
original.extra_editors,
|
||||
);
|
||||
const handleDelete = () =>
|
||||
handleDashboardDelete(
|
||||
original,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
|
||||
import { LocationProvider, useLocationState } from './LocationContext';
|
||||
|
||||
const Probe = () => {
|
||||
const { requestedQuery } = useLocationState();
|
||||
return (
|
||||
<>
|
||||
<span data-test="autorun">{String(requestedQuery?.autorun)}</span>
|
||||
<span data-test="sql">{String(requestedQuery?.sql)}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const setup = (initialEntry: string | { pathname: string; state: object }) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<LocationProvider>
|
||||
<Probe />
|
||||
</LocationProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
test('never derives autorun from the URL querystring', () => {
|
||||
// Regression test: a crafted cross-site GET link must not be able to
|
||||
// auto-execute SQL in the victim's session (top-level navigation sends
|
||||
// SameSite=Lax session cookies).
|
||||
setup('/sqllab?dbid=1&sql=SELECT%20%2A%20FROM%20t&autorun=true');
|
||||
expect(screen.getByTestId('autorun')).toHaveTextContent('false');
|
||||
// The deep link still prefills the editor with the requested SQL.
|
||||
expect(screen.getByTestId('sql')).toHaveTextContent('SELECT * FROM t');
|
||||
});
|
||||
|
||||
test('querystring autorun stays false even when spread from raw params', () => {
|
||||
// `...Object.fromEntries(queryParams)` must not reintroduce the raw
|
||||
// `autorun` string value.
|
||||
setup('/sqllab?sql=SELECT%201&autorun=true');
|
||||
expect(screen.getByTestId('autorun')).toHaveTextContent('false');
|
||||
});
|
||||
|
||||
test('honors autorun from in-app location.state navigations', () => {
|
||||
setup({
|
||||
pathname: '/sqllab',
|
||||
state: { requestedQuery: { sql: 'SELECT 1', autorun: true } },
|
||||
});
|
||||
expect(screen.getByTestId('autorun')).toHaveTextContent('true');
|
||||
});
|
||||
@@ -41,12 +41,14 @@ export const LocationProvider: FC<{ children?: ReactNode }> = ({
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const permalink = location.pathname.match(/\/p\/\w+/)?.[0].slice(3);
|
||||
if (queryParams.size > 0 || permalink) {
|
||||
const autorun = queryParams.get('autorun') === 'true';
|
||||
// Deep links (querystring or permalink) prefill the editor and wait
|
||||
// for the user to press Run. Only in-app navigations that pass
|
||||
// `location.state` (handled above) may request autorun.
|
||||
const isDataset = queryParams.get('isDataset') === 'true';
|
||||
const queryParamsState = {
|
||||
requestedQuery: {
|
||||
...Object.fromEntries(queryParams),
|
||||
autorun,
|
||||
autorun: false,
|
||||
permalink,
|
||||
},
|
||||
isDataset,
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface Chart {
|
||||
cache_timeout: number | null;
|
||||
thumbnail_url?: string;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
tags?: TagType[];
|
||||
last_saved_at?: string;
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface Dashboard {
|
||||
url: string;
|
||||
thumbnail_url?: string | null;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
import rison from 'rison';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import Chart from 'src/types/Chart';
|
||||
import {
|
||||
checkUploadExtensions,
|
||||
handleChartDelete,
|
||||
getAlreadyExists,
|
||||
getEncryptedExtraFieldsNeeded,
|
||||
getFilterValues,
|
||||
@@ -743,3 +747,27 @@ test('getFilterValues', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('handleChartDelete surfaces the blocking alert/report names from a 422', async () => {
|
||||
const guardMessage =
|
||||
'There are associated alerts or reports: TC-081 rerun report';
|
||||
const deleteSpy = jest
|
||||
.spyOn(SupersetClient, 'delete')
|
||||
.mockRejectedValue(
|
||||
new Response(JSON.stringify({ message: guardMessage }), { status: 422 }),
|
||||
);
|
||||
const addDangerToast = jest.fn();
|
||||
try {
|
||||
handleChartDelete(
|
||||
{ id: 1, slice_name: 'blocked chart' } as Chart,
|
||||
jest.fn(),
|
||||
addDangerToast,
|
||||
jest.fn(),
|
||||
);
|
||||
await waitFor(() => expect(addDangerToast).toHaveBeenCalledTimes(1));
|
||||
expect(addDangerToast.mock.calls[0][0]).toContain('TC-081 rerun report');
|
||||
expect(addDangerToast.mock.calls[0][0]).toContain('blocked chart');
|
||||
} finally {
|
||||
deleteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -379,9 +379,9 @@ export function handleChartDelete(
|
||||
else refreshData();
|
||||
addSuccessToast(deletedToast(sliceName));
|
||||
},
|
||||
() => {
|
||||
addDangerToast(deleteFailedToast(sliceName));
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(deleteFailedToast(sliceName, errMsg)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -140,4 +140,59 @@ describe('LeftCell', () => {
|
||||
'http://example.com/sales?type=numeric&label=Sales Data',
|
||||
);
|
||||
});
|
||||
|
||||
test('should not render javascript: URLs as links for column rows', () => {
|
||||
const columnRow = {
|
||||
label: 'Test Column',
|
||||
column_name: 'test_column',
|
||||
};
|
||||
|
||||
render(
|
||||
<LeftCell
|
||||
row={columnRow}
|
||||
rowType="column"
|
||||
url="javascript:alert(document.domain)" // eslint-disable-line no-script-url
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Test Column')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not render script-bearing schemes assembled via templating', () => {
|
||||
const columnRow = {
|
||||
label: 'Test Column',
|
||||
column_name: 'alert(1)',
|
||||
};
|
||||
|
||||
render(
|
||||
<LeftCell
|
||||
row={columnRow}
|
||||
rowType="column"
|
||||
url="javascript:{{metric.column_name}}" // eslint-disable-line no-script-url
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should keep relative URLs as links', () => {
|
||||
const columnRow = {
|
||||
label: 'Test Column',
|
||||
column_name: 'test_column',
|
||||
};
|
||||
|
||||
render(
|
||||
<LeftCell
|
||||
row={columnRow}
|
||||
rowType="column"
|
||||
url="/superset/dashboard/{{metric.column_name}}/"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link')).toHaveAttribute(
|
||||
'href',
|
||||
'/superset/dashboard/test_column/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,23 @@ interface LeftCellProps {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confines a caller-supplied URL to http(s) and relative schemes before
|
||||
* it's rendered as a link. Returns undefined for anything else, degrading
|
||||
* the cell to plain text.
|
||||
*/
|
||||
export const toSafeHref = (url: string): string | undefined => {
|
||||
try {
|
||||
const { protocol } = new URL(url, window.location.origin);
|
||||
if (protocol === 'http:' || protocol === 'https:') {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// fall through: unparseable URLs are not rendered as links
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the left cell containing either column labels or metric information
|
||||
*/
|
||||
@@ -35,7 +52,7 @@ const LeftCell = ({ row, rowType, url }: LeftCellProps): ReactElement => {
|
||||
const fullUrl = useMemo(() => {
|
||||
if (!url) return undefined;
|
||||
const context = { metric: row };
|
||||
return Mustache.render(url, context);
|
||||
return toSafeHref(Mustache.render(url, context));
|
||||
}, [url, row]);
|
||||
|
||||
if (rowType === 'column') {
|
||||
|
||||
@@ -625,6 +625,42 @@ describe('server', () => {
|
||||
);
|
||||
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
|
||||
});
|
||||
|
||||
test('unsolicited pong payload cannot pollute Object.prototype', async () => {
|
||||
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
|
||||
const request = getRequest(validToken, 'http://localhost');
|
||||
|
||||
server.wsConnection(ws, request);
|
||||
|
||||
// Extract the handler registered for the 'pong' event, the same way
|
||||
// the underlying `ws` library would invoke it on a raw pong frame.
|
||||
const pongCall = wsEventMock.mock.calls.find(call => call[0] === 'pong');
|
||||
expect(pongCall).toBeDefined();
|
||||
const pongHandler = pongCall![1] as (data: Buffer) => void;
|
||||
|
||||
// An unsolicited pong with a payload matching an inherited key must not
|
||||
// resolve through the prototype chain and must not write through to
|
||||
// Object.prototype.
|
||||
pongHandler(Buffer.from('__proto__'));
|
||||
pongHandler(Buffer.from('constructor'));
|
||||
pongHandler(Buffer.from('hasOwnProperty'));
|
||||
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
expect(Object.prototype.hasOwnProperty('pongTs')).toBe(false);
|
||||
expect(({} as Record<string, unknown>).pongTs).toBeUndefined();
|
||||
|
||||
// A genuine socket id must still record its pong normally.
|
||||
const socketId = server.channels[channelId].sockets[0];
|
||||
const beforePongTs = server.sockets[socketId].pongTs;
|
||||
dateNowSpy.mockImplementation(() =>
|
||||
new Date('2021-03-10T11:02:58.135Z').valueOf(),
|
||||
);
|
||||
pongHandler(Buffer.from(socketId));
|
||||
expect(server.sockets[socketId].pongTs).not.toBe(beforePongTs);
|
||||
expect(server.sockets[socketId].pongTs).toBe(
|
||||
new Date('2021-03-10T11:02:58.135Z').valueOf(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection limits', () => {
|
||||
|
||||
@@ -475,12 +475,18 @@ export const wsConnection = (ws: WebSocket, request: http.IncomingMessage) => {
|
||||
// init event handler for `pong` events (connection management)
|
||||
ws.on('pong', function pong(data: Buffer) {
|
||||
const socketId = data.toString();
|
||||
const socketInstance = sockets[socketId];
|
||||
if (!socketInstance) {
|
||||
// `sockets` is a plain object, so an unsolicited pong carrying an
|
||||
// inherited key ('__proto__', 'constructor', 'hasOwnProperty', ...) as
|
||||
// its payload would otherwise resolve through the prototype chain
|
||||
// instead of missing outright, letting a client write an enumerable
|
||||
// `pongTs` onto Object.prototype (tripped over by the for...in loops in
|
||||
// checkSockets/cleanChannel on every GC pass). Guarding with an
|
||||
// own-property check rejects every such key in one place.
|
||||
if (!Object.prototype.hasOwnProperty.call(sockets, socketId)) {
|
||||
logger.warn(`pong received for nonexistent socket ${socketId}`);
|
||||
} else {
|
||||
socketInstance.pongTs = Date.now();
|
||||
return;
|
||||
}
|
||||
sockets[socketId].pongTs = Date.now();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -238,17 +238,24 @@ class AsyncQueryManager:
|
||||
secret so the value is unguessable to outside callers.
|
||||
"""
|
||||
token = guest_user.guest_token
|
||||
# HMAC over the complete claim set so that tokens differing in *any*
|
||||
# claim derive distinct channels. Enumerating claims here is unsafe:
|
||||
# omitting one that scopes the session -- most importantly
|
||||
# ``rls_rules``, the primary tenant-isolation mechanism for embedded
|
||||
# dashboards -- would let two tenants' tokens minted in the same
|
||||
# second with identical user/resources collide on one channel,
|
||||
# exposing job events (including error strings) and cross-tenant
|
||||
# cancellation. ``iat`` uniquely identifies a token issuance, so it
|
||||
# provides per-token isolation while remaining stable across the
|
||||
# lifetime of a single embedded session.
|
||||
message = json.dumps(token, sort_keys=True).encode("utf-8")
|
||||
# ``iat`` uniquely identifies a guest token issuance, so it provides
|
||||
# per-token isolation while remaining stable across the lifetime of a
|
||||
# single embedded session.
|
||||
message = json.dumps(
|
||||
{
|
||||
"user": token.get("user"),
|
||||
"resources": token.get("resources"),
|
||||
"iat": token.get("iat"),
|
||||
"exp": token.get("exp"),
|
||||
"aud": token.get("aud"),
|
||||
# ``datasets`` and ``rev`` are optional scope claims, so tokens
|
||||
# that differ only in their dataset allowlist or revocation
|
||||
# version still derive distinct channels.
|
||||
"datasets": token.get("datasets"),
|
||||
"rev": token.get("rev"),
|
||||
},
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
digest = hmac.new(
|
||||
self._jwt_secret.encode("utf-8"), message, hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
+26
-4
@@ -92,7 +92,10 @@ from superset.exceptions import (
|
||||
)
|
||||
from superset.extensions import event_logger, security_manager
|
||||
from superset.models.slice import Slice
|
||||
from superset.security.manager import get_extra_editor_subject_ids
|
||||
from superset.security.manager import (
|
||||
get_extra_editor_subject_ids,
|
||||
get_extra_editors_by_pk,
|
||||
)
|
||||
from superset.subjects.filters import (
|
||||
FilterRelatedSubjects,
|
||||
subject_type_filter,
|
||||
@@ -410,12 +413,21 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
except ChartNotFoundError:
|
||||
return self.response_404()
|
||||
|
||||
def pre_get_list(self, data: dict[str, Any]) -> None:
|
||||
"""Attach ``extra_editors`` to each row, matching the single-object GET."""
|
||||
super().pre_get_list(data)
|
||||
ids = data.get("ids", [])
|
||||
extra_editors_by_id = get_extra_editors_by_pk(Slice, ids)
|
||||
for row, row_id in zip(data.get("result", []), ids, strict=False):
|
||||
if row_id in extra_editors_by_id:
|
||||
row["extra_editors"] = extra_editors_by_id[row_id]
|
||||
|
||||
@expose("/<pk>/deck_layers/", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: (f"{self.__class__.__name__}.deck_layers"),
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.deck_layers",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def deck_layers(self, pk: int) -> Response:
|
||||
@@ -679,13 +691,17 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
normalization_changes: object = item.pop("normalization_changes", None)
|
||||
|
||||
# Live version identifiers before the update (empty + query-free when
|
||||
# ``ENABLE_VERSIONING_CAPTURE`` is off, so this stays inert under the
|
||||
# kill-switch).
|
||||
old_info = current_entity_version_info(Slice, pk)
|
||||
|
||||
try:
|
||||
changed_model = UpdateChartCommand(pk, item).run()
|
||||
changed_model = UpdateChartCommand(
|
||||
pk, item, normalization_changes=normalization_changes
|
||||
).run()
|
||||
new_info = current_entity_version_info(
|
||||
Slice, changed_model.id, changed_model.uuid
|
||||
)
|
||||
@@ -1050,7 +1066,7 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
task_status=cache_payload.get_status(),
|
||||
)
|
||||
|
||||
if cache_payload.should_trigger_task(force):
|
||||
if cache_payload.should_trigger_task(force, expected_scope=f"chart:{chart.id}"):
|
||||
logger.info("Triggering screenshot ASYNC")
|
||||
screenshot_obj.cache.set(cache_key, ScreenshotCachePayload().to_dict())
|
||||
cache_chart_thumbnail.delay(
|
||||
@@ -1112,6 +1128,12 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
return self.response_404()
|
||||
|
||||
if cache_payload := ChartScreenshot.get_from_cache_key(digest):
|
||||
# The digest is caller-supplied and cache entries are shared
|
||||
# across every chart (and, via the same backend, dashboards) --
|
||||
# without this check any cache_key learned for one chart would
|
||||
# serve its image under a different, merely-accessible `pk`.
|
||||
if cache_payload.get_scope() != f"chart:{chart.id}":
|
||||
return self.response_404()
|
||||
if cache_payload.status == StatusValues.UPDATED:
|
||||
try:
|
||||
image = cache_payload.get_image()
|
||||
|
||||
@@ -368,6 +368,30 @@ class ChartPutSchema(Schema):
|
||||
external_url = fields.String(allow_none=True, validate=utils.validate_external_url)
|
||||
tags = fields.List(fields.Integer(metadata={"description": tags_description}))
|
||||
uuid = fields.UUID(allow_none=True)
|
||||
normalization_changes: fields.Raw = fields.Raw(
|
||||
load_only=True,
|
||||
allow_none=True,
|
||||
metadata={
|
||||
"description": (
|
||||
"Optional advisory Explore hydration transitions used only to "
|
||||
"remove exact automatic normalization changes from human-readable "
|
||||
"version history. Invalid metadata is ignored."
|
||||
),
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["control", "from_present", "to_present"],
|
||||
"properties": {
|
||||
"control": {"type": "string", "maxLength": 256},
|
||||
"from_present": {"type": "boolean"},
|
||||
"from_value": {},
|
||||
"to_present": {"type": "boolean"},
|
||||
"to_value": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ChartGetDatasourceObjectDataResponseSchema(Schema):
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
from typing import Optional
|
||||
|
||||
@@ -32,11 +33,62 @@ from superset.daos.chart import ChartDAO
|
||||
from superset.daos.report import ReportScheduleDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.models.slice import Slice
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_blocked_by_reports_message(
|
||||
charts: list[Slice],
|
||||
reports: list[ReportSchedule],
|
||||
single_target: bool,
|
||||
) -> str:
|
||||
"""Build the user-facing message naming the alerts/reports that block deletion.
|
||||
|
||||
Groups the blocking reports per chart, sorted by chart name (chart id as
|
||||
the tie-breaker) and then report name so the message is deterministic
|
||||
across database backends, and appends the remedy sentence. When the
|
||||
command targets a single chart id the group prefix is dropped — in the
|
||||
single-delete endpoint the surrounding toast already names the chart, and
|
||||
in a one-chart bulk selection the user's own selection provides the
|
||||
context. A multi-id command keeps the prefix on every group because the
|
||||
bulk toast is generic, so the prefix is the only chart identification the
|
||||
user gets.
|
||||
"""
|
||||
sentences: list[str] = []
|
||||
if single_target:
|
||||
names = sorted(report.name for report in reports)
|
||||
sentences.append(
|
||||
str(
|
||||
_(
|
||||
"This chart is used by alerts or reports: %(names)s.",
|
||||
names=", ".join(names),
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
report_names_by_chart_id: dict[int, list[str]] = defaultdict(list)
|
||||
for report in reports:
|
||||
report_names_by_chart_id[report.chart_id].append(report.name)
|
||||
charts_by_id = {chart.id: chart for chart in charts}
|
||||
for chart_id, names in sorted(
|
||||
report_names_by_chart_id.items(),
|
||||
key=lambda item: (charts_by_id[item[0]].slice_name or "", item[0]),
|
||||
):
|
||||
sentences.append(
|
||||
str(
|
||||
_(
|
||||
'Chart "%(chart)s" is used by alerts or reports: %(names)s.',
|
||||
chart=charts_by_id[chart_id].slice_name or str(chart_id),
|
||||
names=", ".join(sorted(names)),
|
||||
)
|
||||
)
|
||||
)
|
||||
sentences.append(str(_("Detach or delete them first.")))
|
||||
return " ".join(sentences)
|
||||
|
||||
|
||||
class DeleteChartCommand(BaseCommand):
|
||||
def __init__(self, model_ids: list[int]):
|
||||
self._model_ids = model_ids
|
||||
@@ -55,11 +107,11 @@ class DeleteChartCommand(BaseCommand):
|
||||
raise ChartNotFoundError()
|
||||
# Check there are no associated ReportSchedules
|
||||
if reports := ReportScheduleDAO.find_by_chart_ids(self._model_ids):
|
||||
report_names = [report.name for report in reports]
|
||||
raise ChartDeleteFailedReportsExistError(
|
||||
_(
|
||||
"There are associated alerts or reports: %(report_names)s",
|
||||
report_names=",".join(report_names),
|
||||
build_blocked_by_reports_message(
|
||||
self._models,
|
||||
reports,
|
||||
single_target=len(self._model_ids) == 1,
|
||||
)
|
||||
)
|
||||
# Check editorship
|
||||
|
||||
@@ -44,11 +44,15 @@ from superset.commands.utils import (
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.extensions import db
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.tags.models import ObjectType
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.versioning.changes.normalization import (
|
||||
register_matching_normalization_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,10 +64,16 @@ def is_query_context_update(properties: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
def __init__(self, model_id: int, data: dict[str, Any]):
|
||||
self._model_id = model_id
|
||||
self._properties = data.copy()
|
||||
def __init__(
|
||||
self,
|
||||
model_id: int,
|
||||
data: dict[str, Any],
|
||||
normalization_changes: object = None,
|
||||
) -> None:
|
||||
self._model_id: int = model_id
|
||||
self._properties: dict[str, Any] = data.copy()
|
||||
self._model: Optional[Slice] = None
|
||||
self._normalization_changes: object = normalization_changes
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=ChartUpdateFailedError))
|
||||
def run(self) -> Model:
|
||||
@@ -78,6 +88,15 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
self._properties["last_saved_at"] = datetime.now()
|
||||
self._properties["last_saved_by"] = g.user
|
||||
|
||||
if self._normalization_changes is not None and "params" in self._properties:
|
||||
register_matching_normalization_context(
|
||||
db.session,
|
||||
self._model.id,
|
||||
self._normalization_changes,
|
||||
self._model.params,
|
||||
self._properties["params"],
|
||||
)
|
||||
|
||||
return ChartDAO.update(self._model, self._properties)
|
||||
|
||||
def _validate_new_dashboard_access(
|
||||
|
||||
@@ -21,17 +21,21 @@ from typing import Any
|
||||
from marshmallow.exceptions import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.dashboard.importers import v0, v1
|
||||
from superset.commands.dashboard.importers import v1
|
||||
from superset.commands.exceptions import CommandInvalidError
|
||||
from superset.commands.importers.exceptions import IncorrectVersionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# list of different import formats supported; v0 should be last because
|
||||
# the files are not versioned
|
||||
# list of different import formats supported. The legacy v0 importer is
|
||||
# deliberately NOT dispatched here: it overrides datasets matched by
|
||||
# (table_name, schema, database) and charts/dashboards matched by remote_id
|
||||
# without ownership checks, and this dispatcher is reachable from the HTTP
|
||||
# import endpoint (POST /api/v1/dashboard/import/). Operators can still
|
||||
# import legacy v0 JSON files with the `legacy_import_dashboards` CLI
|
||||
# command, which uses the v0 command directly.
|
||||
command_versions = [
|
||||
v1.ImportDashboardsCommand,
|
||||
v0.ImportDashboardsCommand,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -21,8 +21,10 @@ from typing import Any
|
||||
from flask import current_app as app
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.commands.database.exceptions import DatabaseInvalidError
|
||||
from superset.commands.database.utils import add_permissions
|
||||
from superset.commands.exceptions import ImportFailedError
|
||||
from superset.constants import PASSWORD_MASK
|
||||
from superset.databases.ssh_tunnel.models import SSHTunnel
|
||||
from superset.databases.utils import make_url_safe
|
||||
from superset.db_engine_specs.exceptions import SupersetDBAPIConnectionError
|
||||
@@ -37,6 +39,73 @@ from superset.utils import json
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _connection_identity_changed(existing: Database, config: dict[str, Any]) -> bool:
|
||||
"""Whether the import points the database at a different endpoint."""
|
||||
try:
|
||||
stored = make_url_safe(existing.sqlalchemy_uri)._replace(password=None)
|
||||
incoming = make_url_safe(config["sqlalchemy_uri"])._replace(password=None)
|
||||
except DatabaseInvalidError:
|
||||
# An unparseable URI cannot be compared: treat it as a change so
|
||||
# stored secrets never survive onto it.
|
||||
return True
|
||||
return stored != incoming
|
||||
|
||||
|
||||
def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> None:
|
||||
"""
|
||||
Refuse an overwrite that changes the connection endpoint without fresh
|
||||
credentials.
|
||||
|
||||
Database UUIDs are not secrets -- they appear in every exported bundle --
|
||||
so an import must not be able to repoint an existing connection at a new
|
||||
host while the stored password (or SSH tunnel key) is silently kept: the
|
||||
next connection would hand the real credential to the new endpoint.
|
||||
"""
|
||||
if _connection_identity_changed(existing, config):
|
||||
try:
|
||||
uri_password = make_url_safe(config["sqlalchemy_uri"]).password
|
||||
except DatabaseInvalidError:
|
||||
uri_password = None
|
||||
if config.get("password") in (None, PASSWORD_MASK) and uri_password in (
|
||||
None,
|
||||
PASSWORD_MASK,
|
||||
):
|
||||
raise ImportFailedError(
|
||||
f"Import would change the connection of database "
|
||||
f"'{existing.database_name}' without providing new "
|
||||
"credentials. Re-enter the database password for the new "
|
||||
"connection to confirm the change."
|
||||
)
|
||||
|
||||
if ssh_tunnel := config.get("ssh_tunnel"):
|
||||
existing_tunnel = existing.ssh_tunnel
|
||||
if existing_tunnel and (
|
||||
ssh_tunnel.get("server_address") != existing_tunnel.server_address
|
||||
or ssh_tunnel.get("server_port") != existing_tunnel.server_port
|
||||
):
|
||||
has_fresh_credential = any(
|
||||
ssh_tunnel.get(field) not in (None, PASSWORD_MASK)
|
||||
for field in ("password", "private_key")
|
||||
)
|
||||
# A passphrase-protected private key's stored passphrase is a
|
||||
# secret in its own right: if the existing tunnel had one, a
|
||||
# repoint that supplies a fresh private_key but leaves
|
||||
# private_key_password masked/absent would keep the old
|
||||
# passphrase attached to the new key rather than requiring the
|
||||
# importer to confirm it too.
|
||||
stale_private_key_password = (
|
||||
existing_tunnel.private_key_password is not None
|
||||
and ssh_tunnel.get("private_key_password") in (None, PASSWORD_MASK)
|
||||
)
|
||||
if not has_fresh_credential or stale_private_key_password:
|
||||
raise ImportFailedError(
|
||||
f"Import would change the SSH tunnel endpoint of database "
|
||||
f"'{existing.database_name}' without providing new tunnel "
|
||||
"credentials. Re-enter the SSH tunnel credentials to "
|
||||
"confirm the change."
|
||||
)
|
||||
|
||||
|
||||
def import_database( # noqa: C901
|
||||
config: dict[str, Any],
|
||||
overwrite: bool = False,
|
||||
@@ -51,6 +120,11 @@ def import_database( # noqa: C901
|
||||
if not overwrite or not can_write:
|
||||
return existing
|
||||
config["id"] = existing.id
|
||||
# Stored secrets must not be rebound to a different endpoint: without
|
||||
# fresh credentials, an overwrite that changes where the database (or
|
||||
# its SSH tunnel) connects would exfiltrate the stored secret to the
|
||||
# new endpoint on the next connection.
|
||||
_refuse_stored_secret_reuse(existing, config)
|
||||
elif not can_write:
|
||||
raise ImportFailedError(
|
||||
"Database doesn't exist and user doesn't have permission to create databases" # noqa: E501
|
||||
@@ -81,7 +155,13 @@ def import_database( # noqa: C901
|
||||
# For existing DBs, reveal masked sensitive values from current encrypted_extra.
|
||||
# For new DBs, schema validation already ensured no fields are still masked.
|
||||
if masked_encrypted_extra := config.pop("masked_encrypted_extra", None):
|
||||
if existing and existing.encrypted_extra:
|
||||
# Never reveal stored encrypted_extra secrets into a config that
|
||||
# repoints the connection at a different endpoint.
|
||||
if (
|
||||
existing
|
||||
and existing.encrypted_extra
|
||||
and not _connection_identity_changed(existing, config)
|
||||
):
|
||||
old_config = json.loads(existing.encrypted_extra)
|
||||
new_config = json.loads(masked_encrypted_extra)
|
||||
sensitive_fields = (
|
||||
|
||||
@@ -21,17 +21,21 @@ from typing import Any
|
||||
from marshmallow.exceptions import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.dataset.importers import v0, v1
|
||||
from superset.commands.dataset.importers import v1
|
||||
from superset.commands.exceptions import CommandInvalidError
|
||||
from superset.commands.importers.exceptions import IncorrectVersionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# list of different import formats supported; v0 should be last because
|
||||
# the files are not versioned
|
||||
# list of different import formats supported. The legacy v0 importer is
|
||||
# deliberately NOT dispatched here: it overrides datasets matched by
|
||||
# (table_name, schema, database) without ownership checks, and this
|
||||
# dispatcher is reachable from the HTTP import endpoint
|
||||
# (POST /api/v1/dataset/import/). Operators can still import legacy v0
|
||||
# YAML files with the `legacy_import_datasources` CLI command, which uses
|
||||
# the v0 command directly.
|
||||
command_versions = [
|
||||
v1.ImportDatasetsCommand,
|
||||
v0.ImportDatasetsCommand,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import gzip
|
||||
import io
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
@@ -611,6 +612,29 @@ def _convert_temporal_columns(df: pd.DataFrame, dtype: dict[str, Any]) -> None:
|
||||
df[column_name] = converted
|
||||
|
||||
|
||||
def _read_bounded(stream: Any, max_bytes: int) -> io.BytesIO:
|
||||
"""
|
||||
Read ``stream`` into memory, failing once more than ``max_bytes`` bytes
|
||||
have been produced.
|
||||
|
||||
Bounds both the raw download and gzip decompression amplification for
|
||||
dataset data URIs: the ``.gz`` path had no analogue of
|
||||
``check_is_safe_zip`` and allowed unbounded expansion from a small
|
||||
payload.
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
# Both http.client responses and gzip.open() yield bytes; a handful
|
||||
# of tests substitute a text stream, so normalize either shape.
|
||||
if isinstance(chunk, str):
|
||||
chunk = chunk.encode("utf-8")
|
||||
buffer.write(chunk)
|
||||
if buffer.tell() > max_bytes:
|
||||
raise ImportFailedError("Data URI payload exceeds the maximum allowed size")
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
|
||||
def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
|
||||
"""
|
||||
Load data from a data URI into a dataset.
|
||||
@@ -637,9 +661,15 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
|
||||
handlers.extend([_PeerValidatingHTTPHandler, _PeerValidatingHTTPSHandler])
|
||||
opener = request.build_opener(*handlers)
|
||||
data = opener.open(data_uri) # pylint: disable=consider-using-with # noqa: S310
|
||||
# Cap the bytes materialized from the download, before and after gzip
|
||||
# decompression (same per-file knob as ZIP bundle uploads): a gzip
|
||||
# stream can carry oversized headers, trailing data, or additional
|
||||
# members that would otherwise let the raw (compressed) download exceed
|
||||
# the limit even when the decompressed CSV stays within it.
|
||||
max_bytes = app.config["ZIPPED_FILE_MAX_SIZE"]
|
||||
if data_uri.endswith(".gz"):
|
||||
data = gzip.open(data)
|
||||
df = pd.read_csv(data, encoding="utf-8")
|
||||
data = gzip.open(_read_bounded(data, max_bytes))
|
||||
df = pd.read_csv(_read_bounded(data, max_bytes), encoding="utf-8")
|
||||
dtype = get_dtype(df, dataset)
|
||||
|
||||
_convert_temporal_columns(df, dtype)
|
||||
|
||||
@@ -27,6 +27,7 @@ from sqlalchemy.orm import Session
|
||||
from superset import db
|
||||
from superset.commands.importers.exceptions import IncorrectVersionError
|
||||
from superset.databases.ssh_tunnel.models import SSHTunnel
|
||||
from superset.databases.utils import make_url_safe
|
||||
from superset.extensions import feature_flag_manager
|
||||
from superset.models.core import Database
|
||||
from superset.models.dashboard import dashboard_slices
|
||||
@@ -103,6 +104,34 @@ def validate_metadata_type(
|
||||
exceptions.append(exc)
|
||||
|
||||
|
||||
def database_connection_identity_unchanged(
|
||||
stored_uri: Optional[str], incoming_uri: Optional[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Whether an incoming database config still points at the same connection
|
||||
(driver, host, port -- everything except the credential) as the stored one.
|
||||
|
||||
Stored secrets may only be re-attached to an import when this holds:
|
||||
database UUIDs are not secrets (they appear in every exported bundle and
|
||||
in API responses), so re-attaching secrets on a UUID match alone would
|
||||
let a hostile bundle repoint an existing connection at an
|
||||
attacker-controlled server that then receives the victim's real
|
||||
credentials.
|
||||
"""
|
||||
if not stored_uri or not incoming_uri:
|
||||
return False
|
||||
try:
|
||||
stored = make_url_safe(stored_uri)._replace(password=None)
|
||||
incoming = make_url_safe(incoming_uri)._replace(password=None)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# An unparseable URI cannot be compared; never attach secrets to it.
|
||||
return False
|
||||
# Compare the full URL minus the credential, not just host/port: query
|
||||
# arguments become driver connect args and can themselves redirect the
|
||||
# connection (e.g. ``?host=`` for postgres drivers).
|
||||
return stored == incoming
|
||||
|
||||
|
||||
# pylint: disable=too-many-locals,too-many-arguments
|
||||
# ruff: noqa: C901
|
||||
def load_configs(
|
||||
@@ -141,6 +170,21 @@ def load_configs(
|
||||
SSHTunnel.uuid, SSHTunnel.private_key_password
|
||||
).all()
|
||||
}
|
||||
# load connection endpoints so stored secrets are only re-attached to a
|
||||
# config that still points at the same endpoint (see
|
||||
# database_connection_identity_unchanged)
|
||||
db_sqlalchemy_uris: dict[str, str] = {
|
||||
str(uuid): sqlalchemy_uri
|
||||
for uuid, sqlalchemy_uri in db.session.query(
|
||||
Database.uuid, Database.sqlalchemy_uri
|
||||
).all()
|
||||
}
|
||||
db_ssh_tunnel_servers: dict[str, tuple[Any, Any]] = {
|
||||
str(uuid): (server_address, server_port)
|
||||
for uuid, server_address, server_port in db.session.query(
|
||||
SSHTunnel.uuid, SSHTunnel.server_address, SSHTunnel.server_port
|
||||
).all()
|
||||
}
|
||||
for file_name, content in contents.items():
|
||||
# skip directories
|
||||
if not content:
|
||||
@@ -151,6 +195,38 @@ def load_configs(
|
||||
if schema:
|
||||
try:
|
||||
config = load_yaml(file_name, content)
|
||||
if not isinstance(config, dict):
|
||||
# A syntactically valid YAML document whose top-level
|
||||
# value is a scalar or list (not a mapping) has no
|
||||
# fields to validate against the schema; report it the
|
||||
# same way as unparseable YAML instead of letting the
|
||||
# ``.get()`` calls below raise an unhandled AttributeError.
|
||||
raise ValidationError({file_name: "Not a valid YAML file"})
|
||||
|
||||
# Stored secrets are only reusable when the incoming config
|
||||
# still points at the same endpoint as the stored one; a UUID
|
||||
# match alone must never rebind stored credentials to a new
|
||||
# host (see database_connection_identity_unchanged).
|
||||
db_secrets_reusable = (
|
||||
prefix == "databases"
|
||||
and database_connection_identity_unchanged(
|
||||
db_sqlalchemy_uris.get(str(config.get("uuid"))),
|
||||
config.get("sqlalchemy_uri"),
|
||||
)
|
||||
)
|
||||
incoming_tunnel = config.get("ssh_tunnel") or {}
|
||||
stored_tunnel_server = db_ssh_tunnel_servers.get(
|
||||
str(config.get("uuid"))
|
||||
)
|
||||
tunnel_secrets_reusable = (
|
||||
prefix == "databases"
|
||||
and stored_tunnel_server is not None
|
||||
and (
|
||||
incoming_tunnel.get("server_address"),
|
||||
incoming_tunnel.get("server_port"),
|
||||
)
|
||||
== stored_tunnel_server
|
||||
)
|
||||
|
||||
# populate passwords from the request, from YAML config,
|
||||
# or from existing DBs
|
||||
@@ -159,14 +235,15 @@ def load_configs(
|
||||
elif prefix == "databases" and config.get("password"):
|
||||
# password already in YAML config, keep it
|
||||
pass
|
||||
elif prefix == "databases" and config["uuid"] in db_passwords:
|
||||
elif db_secrets_reusable and config["uuid"] in db_passwords:
|
||||
config["password"] = db_passwords[config["uuid"]]
|
||||
|
||||
# populate ssh_tunnel_passwords from the request or from existing DBs
|
||||
if file_name in ssh_tunnel_passwords:
|
||||
config["ssh_tunnel"]["password"] = ssh_tunnel_passwords[file_name]
|
||||
elif (
|
||||
prefix == "databases" and config["uuid"] in db_ssh_tunnel_passwords
|
||||
tunnel_secrets_reusable
|
||||
and config["uuid"] in db_ssh_tunnel_passwords
|
||||
):
|
||||
config["ssh_tunnel"]["password"] = db_ssh_tunnel_passwords[
|
||||
config["uuid"]
|
||||
@@ -178,7 +255,7 @@ def load_configs(
|
||||
file_name
|
||||
]
|
||||
elif (
|
||||
prefix == "databases"
|
||||
tunnel_secrets_reusable
|
||||
and config["uuid"] in db_ssh_tunnel_private_keys
|
||||
):
|
||||
config["ssh_tunnel"]["private_key"] = db_ssh_tunnel_private_keys[
|
||||
@@ -191,7 +268,7 @@ def load_configs(
|
||||
ssh_tunnel_priv_key_passwords[file_name]
|
||||
)
|
||||
elif (
|
||||
prefix == "databases"
|
||||
tunnel_secrets_reusable
|
||||
and config["uuid"] in db_ssh_tunnel_priv_key_passws
|
||||
):
|
||||
config["ssh_tunnel"]["private_key_password"] = (
|
||||
|
||||
@@ -17,16 +17,52 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from superset import db
|
||||
from superset import db, security_manager
|
||||
from superset.commands.exceptions import ImportFailedError
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
from superset.utils.core import get_user
|
||||
|
||||
|
||||
def import_saved_query(config: dict[str, Any], overwrite: bool = False) -> SavedQuery:
|
||||
def import_saved_query(
|
||||
config: dict[str, Any],
|
||||
overwrite: bool = False,
|
||||
ignore_permissions: bool = False,
|
||||
) -> SavedQuery:
|
||||
"""Import a saved query from a config dict, handling existing matches.
|
||||
|
||||
A saved query is a personal, per-user asset: the REST API scopes read,
|
||||
update and delete to ``created_by == g.user`` (``SavedQueryFilter``).
|
||||
The same object-level rule is enforced here on the overwrite path so an
|
||||
importer cannot replace another user's saved query (and the SQL the
|
||||
victim will later run under their own grants) by reusing its UUID in an
|
||||
import bundle -- matching the permission checks every sibling importer
|
||||
(chart, dashboard, dataset, database, theme) already performs.
|
||||
"""
|
||||
can_write = ignore_permissions or security_manager.can_access(
|
||||
"can_write",
|
||||
"SavedQuery",
|
||||
)
|
||||
existing = db.session.query(SavedQuery).filter_by(uuid=config["uuid"]).first()
|
||||
if existing:
|
||||
if not overwrite:
|
||||
if not overwrite or not can_write:
|
||||
return existing
|
||||
# ``user`` is None on background paths (no Flask request user);
|
||||
# combined with ``can_write`` (typically from
|
||||
# ``ignore_permissions=True``) the ownership check is skipped there
|
||||
# because the caller has already established trust -- mirroring the
|
||||
# chart importer.
|
||||
user = get_user()
|
||||
if user and not (security_manager.is_admin() or existing.created_by == user):
|
||||
raise ImportFailedError(
|
||||
f"Saved query (uuid {config['uuid']}) already exists and "
|
||||
"user doesn't have permissions to overwrite it"
|
||||
)
|
||||
config["id"] = existing.id
|
||||
elif not can_write:
|
||||
raise ImportFailedError(
|
||||
"Saved query doesn't exist and user doesn't have permission to "
|
||||
"create saved queries"
|
||||
)
|
||||
|
||||
saved_query = SavedQuery.import_from_dict(config, recursive=False)
|
||||
if saved_query.id is None:
|
||||
|
||||
@@ -154,7 +154,7 @@ class CreateReportScheduleCommand(CreateMixin, BaseReportScheduleCommand):
|
||||
if (
|
||||
creation_method != ReportCreationMethod.ALERTS_REPORTS
|
||||
and not ReportScheduleDAO.validate_unique_creation_method(
|
||||
dashboard_id, chart_id
|
||||
dashboard_id, chart_id, creation_method
|
||||
)
|
||||
):
|
||||
raise ReportScheduleCreationMethodUniquenessValidationError()
|
||||
|
||||
@@ -29,7 +29,9 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewCreateFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.utils import json
|
||||
@@ -92,9 +94,13 @@ class CreateSemanticViewCommand(BaseCommand):
|
||||
|
||||
def validate(self) -> None:
|
||||
layer_uuid: str = self._properties.get("semantic_layer_uuid", "")
|
||||
if not SemanticLayerDAO.find_by_uuid(layer_uuid):
|
||||
layer = SemanticLayerDAO.find_by_uuid(layer_uuid)
|
||||
if not layer:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(layer):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
name: str = self._properties.get("name", "")
|
||||
configuration: dict[str, Any] = self._properties.get("configuration") or {}
|
||||
if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, configuration):
|
||||
|
||||
@@ -21,17 +21,17 @@ from functools import partial
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewDeleteFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
@@ -60,6 +60,9 @@ class DeleteSemanticLayerCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticLayerForbiddenError()
|
||||
|
||||
|
||||
class DeleteSemanticViewCommand(BaseCommand):
|
||||
def __init__(self, pk: int):
|
||||
@@ -82,10 +85,8 @@ class DeleteSemanticViewCommand(BaseCommand):
|
||||
self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
try:
|
||||
security_manager.raise_for_editorship(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
|
||||
class BulkDeleteSemanticViewCommand(BaseCommand):
|
||||
@@ -109,7 +110,5 @@ class BulkDeleteSemanticViewCommand(BaseCommand):
|
||||
if len(self._models) != len(self._model_ids):
|
||||
raise SemanticViewNotFoundError()
|
||||
for model in self._models:
|
||||
try:
|
||||
security_manager.raise_for_editorship(model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user