Compare commits

...

10 Commits

Author SHA1 Message Date
Evan
2f57602094 fix(charts): strip duplicate extension from client-supplied export filename
The non-streaming CSV/XLSX/zip branch of _send_chart_response passed a
client-supplied filename straight into generate_download_headers(), which
always appends the format extension, producing doubled extensions like
"export.csv.csv" whenever the caller's filename already ends in
.csv/.xlsx/.zip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 07:10:28 -07:00
Evan
b4788d2e21 fix(charts): pass filename through to non-streaming CSV/XLSX/zip export headers
The /data endpoint already extracts a sanitized filename (falling back to
a chart-name-based default) for the streaming CSV export path, but the
non-streaming CSV, XLSX, and multi-query zip branches of
_send_chart_response ignored it and always called
generate_download_headers() with no filename, producing generic
timestamp-only download names.

Fixes #37487
2026-07-18 00:47:23 -07:00
dependabot[bot]
f6acf68dfa chore(deps): bump react-intersection-observer from 10.0.3 to 10.1.0 in /superset-frontend (#42181)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 13:31:08 +07:00
dependabot[bot]
cce77b42f6 chore(deps-dev): bump @formatjs/intl-durationformat from 0.10.16 to 0.10.17 in /superset-frontend (#42184)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 13:11:17 +07:00
dependabot[bot]
e495fd80f6 chore(deps-dev): bump eslint from 10.6.0 to 10.7.0 in /superset-frontend (#42188)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 13:01:56 +07:00
dependabot[bot]
bda62cb28d chore(deps-dev): bump hdbcli from 2.29.23 to 2.29.25 (#42170)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-18 12:38:16 +07:00
Đỗ Trọng Hải
45354994a6 build(deps): update @rjsf deps to v6 (#41955)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-18 10:54:40 +07:00
Abhishek Kumbhar
0f61d9a3fb fix(bigquery): ensure BigQuery client uses project from engine URI (#41975) 2026-07-17 20:05:34 -07:00
Evan Rusackas
b432f8c917 fix(docs): use modern driver package for CrateDB (#42125)
Co-authored-by: Andreas Motl <andreas.motl@crate.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 10:05:09 +07:00
Igor Shargin
25f6c6c80b fix(pivot-table): add bottom border to last grouped row (#36081)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
2026-07-17 19:26:55 -07:00
20 changed files with 758 additions and 57 deletions

View File

@@ -10,7 +10,6 @@ updates:
- package-ecosystem: "npm"
ignore:
- dependency-name: "@rjsf/*"
# TODO: remove below entries once the application supports React >= 19.0.0
- dependency-name: "react"
update-types: ["version-update:semver-major"]
@@ -49,6 +48,23 @@ updates:
labels:
- npm
- dependabot
groups:
rjsf:
patterns:
- "@rjsf/*"
typescript-eslint:
patterns:
- "@typescript-eslint/*"
- "typescript-eslint"
babel:
patterns:
- "@babel/*"
deckgl:
patterns:
- "@deck.gl/*"
lumagl:
patterns:
- "@luma.gl/*"
open-pull-requests-limit: 30
versioning-strategy: increase
cooldown:

View File

@@ -737,6 +737,7 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
---
- [38358](https://github.com/apache/superset/pull/38358): Switched CrateDB PyPI package from `crate[sqlalchemy]` to `sqlalchemy-cratedb`.
- [35621](https://github.com/apache/superset/pull/35621): The default hash algorithm has changed from MD5 to SHA-256 for improved security and FedRAMP compliance. This affects cache keys for thumbnails, dashboard digests, chart digests, and filter option names. Existing cached data will be invalidated upon upgrade. To opt out of this change and maintain backward compatibility, set `HASH_ALGORITHM = "md5"` in your `superset_config.py`.
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.

View File

@@ -3672,7 +3672,7 @@
"drivers": [
{
"name": "crate",
"pypi_package": "crate[sqlalchemy]",
"pypi_package": "sqlalchemy-cratedb",
"connection_string": "crate://{host}:{port}",
"is_recommended": true
}

View File

@@ -165,7 +165,7 @@ firebird = ["sqlalchemy-firebird>=0.8.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=26.4.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
hana = ["hdbcli==2.29.23", "sqlalchemy_hana==3.0.3"]
hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7.0",

View File

@@ -75,7 +75,7 @@ module.exports = {
// @ant-design/colors and @ant-design/fast-color are allowed through because
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
// from its CJS output, so babel-jest must transform those files.
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact)',
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
],
preset: 'ts-jest',
transform: {

View File

@@ -128,7 +128,7 @@
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^18.3.0",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^10.0.3",
"react-intersection-observer": "^10.1.0",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
@@ -178,7 +178,7 @@
"@babel/types": "^7.29.7",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.16",
"@formatjs/intl-durationformat": "^0.10.17",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.61.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
@@ -227,7 +227,7 @@
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"eslint": "^10.6.0",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
@@ -4093,38 +4093,38 @@
}
},
"node_modules/@formatjs/bigdecimal": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.6.tgz",
"integrity": "sha512-aPzKsGQOkQRHUEbyO/ZtYfr4EqaBQnSs6U4tzTla1xBnIdEHgY2GqEqso28UMwWRkzKqqTj5+/6BmuOsRkfn2A==",
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.7.tgz",
"integrity": "sha512-B4+XY/e4u5v261r3a37Kq2ax91gPZSKGyVpx8X5dygAggLlUz+6MeBpmAhaBFGB8WQ4JB1qD+QtKHk9aJkEb9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@formatjs/fast-memoize": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz",
"integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz",
"integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@formatjs/intl-durationformat": {
"version": "0.10.16",
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.16.tgz",
"integrity": "sha512-Ponn8K7eQAA7qeZRRD85lrvsByfeRDxw6aEE9EXapQ47wknptM0aYgumImVsMaBdJikpWYryGAMRZIB7IYYfmQ==",
"version": "0.10.17",
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.17.tgz",
"integrity": "sha512-YXm0u5WMaSQ8Gr3th33RFNzoI24nMDScg6iZ9EQZk2CNY3Sg19qklq+FQIuuQEbrDwBlQGrMSDE97m4mETnOxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/bigdecimal": "0.2.6",
"@formatjs/intl-localematcher": "0.8.11"
"@formatjs/bigdecimal": "0.2.7",
"@formatjs/intl-localematcher": "0.8.12"
}
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.11.tgz",
"integrity": "sha512-MzBbR9ifqgtzHwUl2PGEDtRVZGXF9nXPtrMyN6/iKbQaim1Ob7fnPJQMllVkHm3RK74o5VqnLrZIE9egWJhf5g==",
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.12.tgz",
"integrity": "sha512-5H3r5ZJ2jZqHEv9K343lvHmeMDKMxssawAVD2H4J9xtu0ZXb6MlNxwLqdwBxJSHFU0C24KSZnffgmAi+59mK4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "3.1.6"
"@formatjs/fast-memoize": "3.1.7"
}
},
"node_modules/@gar/promise-retry": {
@@ -19418,9 +19418,9 @@
}
},
"node_modules/eslint": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz",
"integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -20373,9 +20373,9 @@
"license": "Apache-2.0"
},
"node_modules/fast-equals": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.0.tgz",
"integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.2.tgz",
"integrity": "sha512-sAjhj9ZhOxYCGiNMnZLaucOqf5ZeFnHNoKoAZiD9thhJ0N8RP85qJK759/97C/3L7NzzmGVB5uiX9AUpySZmUQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -35218,9 +35218,9 @@
}
},
"node_modules/react-intersection-observer": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.3.tgz",
"integrity": "sha512-luICLMbs0zxTO/70Zy7K5jOXkABPEVSAF8T3FdZUlctsrIaPLmx8TZe2SSA+CY2HGWfz2INyNTnp82pxNNsShA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.1.0.tgz",
"integrity": "sha512-V8HDu3+Llg6OEhOxx8LnUSS0t4VS+1Xk9ZatkI8Jct/H0CwKnqFTCu8NT3q7ghJTghTdIrEMPSWr2dkKPG+gdQ==",
"license": "MIT",
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
@@ -44218,6 +44218,10 @@
"license": "Apache-2.0",
"devDependencies": {
"@babel/types": "^7.29.7",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "*",
"@types/jest": "^30.0.0",
"jest": "^30.4.2"
},

View File

@@ -213,7 +213,7 @@
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^18.3.0",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^10.0.3",
"react-intersection-observer": "^10.1.0",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
@@ -263,7 +263,7 @@
"@babel/types": "^7.29.7",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.16",
"@formatjs/intl-durationformat": "^0.10.17",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.61.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
@@ -312,7 +312,7 @@
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"eslint": "^10.6.0",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",

View File

@@ -38,6 +38,10 @@
},
"devDependencies": {
"@babel/types": "^7.29.7",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "*",
"@types/jest": "^30.0.0",
"jest": "^30.4.2"
}

View File

@@ -122,6 +122,10 @@ export const Styles = styled.div<{ isDashboardEditMode: boolean }>`
vertical-align: baseline;
}
table.pvtTable tbody tr th.pvtRowLabel.pvtRowLabelLast {
border-bottom: 1px solid ${theme.colorSplit};
}
.pvtTotal,
.pvtGrandTotal {
font-weight: ${theme.fontWeightStrong};

View File

@@ -133,6 +133,7 @@ interface PivotSettings {
maxColVisible?: number;
rowAttrSpans?: number[][];
colAttrSpans?: number[][];
visibleRowCount?: number;
}
const parseLabel = (value: unknown): string | number => {
@@ -804,6 +805,7 @@ export function TableRenderer(props: TableRendererProps) {
maxColVisible: Math.max(...visibleColKeys.map((k: string[]) => k.length)),
rowAttrSpans: calcAttrSpans(visibleRowKeys, rowAttrs.length),
colAttrSpans: calcAttrSpans(visibleColKeys, colAttrs.length),
visibleRowCount: visibleRowKeys.length + (colTotals ? 1 : 0),
allowRenderHtml,
...basePivotSettings,
};
@@ -1228,6 +1230,7 @@ export function TableRenderer(props: TableRendererProps) {
rowTotalCallbacks,
namesMapping,
allowRenderHtml: settingsAllowRenderHtml,
visibleRowCount,
} = settings;
const {
@@ -1264,6 +1267,11 @@ export function TableRenderer(props: TableRendererProps) {
}
const rowSpan = rowAttrSpans![rowIdx][i];
if (rowSpan > 0) {
const isLastRow = rowIdx + rowSpan === visibleRowCount;
let cellClassName = valueCellClassName;
if (isLastRow) {
cellClassName += ' pvtRowLabelLast';
}
const flatRowKeySlice = flatKey(rowKey.slice(0, i + 1));
const colSpan =
1 + (i === settingsRowAttrs.length - 1 ? colIncrSpan : 0);
@@ -1298,7 +1306,7 @@ export function TableRenderer(props: TableRendererProps) {
return (
<th
key={`rowKeyLabel-${i}`}
className={valueCellClassName}
className={cellClassName}
style={rowHeaderStyle}
rowSpan={rowSpan}
colSpan={colSpan}

View File

@@ -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 '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import PivotTableChart from '../src/PivotTableChart';
import transformProps from '../src/plugin/transformProps';
import testData from './testData';
import { ProviderWrapper } from './testHelpers';
test('applies pvtRowLabelLast class to last data row when colTotals is disabled', () => {
const transformedProps = {
...transformProps(testData.withoutColTotals),
margin: 32,
legacy_order_by: null,
order_desc: false,
};
const { container } = render(
ProviderWrapper({
children: <PivotTableChart {...transformedProps} />,
}),
);
const tableBody = container.querySelector('tbody');
const dataRows = Array.from(tableBody?.querySelectorAll('tr') ?? []).filter(
row => !row.classList.contains('pvtRowTotals'),
);
// Get the last data row
const lastDataRow = dataRows[dataRows.length - 1];
expect(lastDataRow).toBeInTheDocument();
// Check if any cell in the last data row has pvtRowLabelLast class
const lastRowCells = lastDataRow.querySelectorAll('th.pvtRowLabel');
const hasLastClass = Array.from(lastRowCells).some(cell =>
cell.classList.contains('pvtRowLabelLast'),
);
expect(hasLastClass).toBe(true);
});
test('does not apply pvtRowLabelLast class to last data row when colTotals is enabled', () => {
const transformedProps = {
...transformProps(testData.withColTotals),
margin: 32,
legacy_order_by: null,
order_desc: false,
};
const { container } = render(
ProviderWrapper({
children: <PivotTableChart {...transformedProps} />,
}),
);
const tableBody = container.querySelector('tbody');
const dataRows = Array.from(tableBody?.querySelectorAll('tr') ?? []).filter(
row => !row.classList.contains('pvtRowTotals'),
);
// Get the last data row (before totals row)
const lastDataRow = dataRows[dataRows.length - 1];
expect(lastDataRow).toBeInTheDocument();
// Check if any cell in the last data row has pvtRowLabelLast class
const lastRowCells = lastDataRow.querySelectorAll('th.pvtRowLabel');
const hasLastClass = Array.from(lastRowCells).some(cell =>
cell.classList.contains('pvtRowLabelLast'),
);
// Should NOT have the class because totals row will have the border
expect(hasLastClass).toBe(false);
// Verify totals row exists
const totalsRow = container.querySelector('tr.pvtRowTotals');
expect(totalsRow).toBeInTheDocument();
});
test('applies pvtRowLabelLast to the spanning outer <th> that bottoms out the table', () => {
// Two row dimensions ([country, city]) so the "Spain" outer <th> spans two
// data rows (rowSpan 2). This is the merged-cell case #36031 is about; the
// existing flat fixtures (every rowSpan 1) never exercise it.
const transformedProps = {
...transformProps(testData.groupedRowsWithoutColTotals),
margin: 32,
legacy_order_by: null,
order_desc: false,
};
const { container } = render(
ProviderWrapper({
children: <PivotTableChart {...transformedProps} />,
}),
);
// Find the outer row-label <th> that spans multiple data rows.
const spanningLabels = Array.from(
container.querySelectorAll('tbody th.pvtRowLabel'),
).filter(cell => Number(cell.getAttribute('rowspan')) >= 2);
// Sanity check: the fixture actually produced a merged cell.
expect(spanningLabels.length).toBeGreaterThan(0);
const spanningCell = spanningLabels[spanningLabels.length - 1];
expect(spanningCell).toHaveTextContent('Spain');
expect(spanningCell.getAttribute('rowspan')).toBe('2');
// The spanning cell bottoms out the table (rowIdx + rowSpan === visibleRowCount)
// so it must carry the border class.
expect(spanningCell).toHaveClass('pvtRowLabelLast');
});
test('withholds pvtRowLabelLast from the spanning <th> when colTotals owns the bottom', () => {
const transformedProps = {
...transformProps(testData.groupedRowsWithColTotals),
margin: 32,
legacy_order_by: null,
order_desc: false,
};
const { container } = render(
ProviderWrapper({
children: <PivotTableChart {...transformedProps} />,
}),
);
const spanningLabels = Array.from(
container.querySelectorAll('tbody th.pvtRowLabel'),
).filter(cell => Number(cell.getAttribute('rowspan')) >= 2);
expect(spanningLabels.length).toBeGreaterThan(0);
const spanningCell = spanningLabels[spanningLabels.length - 1];
expect(spanningCell).toHaveTextContent('Spain');
// The totals row now bottoms out the table, so no row-label <th> should get
// the border class.
const anyLabelHasLastClass = Array.from(
container.querySelectorAll('tbody th.pvtRowLabel'),
).some(cell => cell.classList.contains('pvtRowLabelLast'));
expect(anyLabelHasLastClass).toBe(false);
// The totals row owns the bottom edge instead.
const totalsRow = container.querySelector('tr.pvtRowTotals');
expect(totalsRow).toBeInTheDocument();
});

View File

@@ -0,0 +1,232 @@
/**
* 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 {
ChartDataResponseResult,
ChartProps,
DatasourceType,
VizType,
QueryFormData,
} from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
const basicFormData: QueryFormData = {
datasource: '1__table',
viz_type: VizType.PivotTable,
groupbyRows: ['country'],
groupbyColumns: ['city'],
metrics: ['SUM(sales)'],
metricsLayout: 'COLUMNS',
rowOrder: 'key_a_to_z',
colOrder: 'key_a_to_z',
aggregateFunction: 'Sum',
rowSubTotals: false,
colTotals: true,
colSubTotals: false,
rowTotals: true,
valueFormat: 'SMART_NUMBER',
dateFormat: 'smart_date',
transposePivot: false,
combineMetric: false,
rowSubtotalPosition: false,
colSubtotalPosition: false,
};
const basicChartProps = {
width: 800,
height: 600,
datasource: {
id: 1,
name: 'test_dataset',
type: DatasourceType.Table,
columns: [],
metrics: [],
columnFormats: {},
verboseMap: {},
},
hooks: {},
initialValues: {},
queriesData: [
{
data: {
columns: [],
records: [],
},
},
],
formData: basicFormData,
theme: supersetTheme,
};
const basicQueryResult: ChartDataResponseResult = {
annotation_data: null,
cache_key: null,
cached_dttm: null,
cache_timeout: null,
data: [],
colnames: [],
coltypes: [],
error: null,
is_cached: false,
query: 'SELECT ...',
rowcount: 100,
sql_rowcount: 100,
stacktrace: null,
status: 'success',
from_dttm: null,
to_dttm: null,
queried_dttm: null,
};
// Shared test data
const pivotData = [
{ country: 'France', city: 'Paris', 'SUM(sales)': 1000 },
{ country: 'Germany', city: 'Berlin', 'SUM(sales)': 2000 },
{ country: 'Spain', city: 'Madrid', 'SUM(sales)': 1500 },
{ country: 'Italy', city: 'Rome', 'SUM(sales)': 3000 },
{ country: 'UK', city: 'London', 'SUM(sales)': 2500 },
];
// Shared query result structure
const basicQueriesData = [
{
...basicQueryResult,
colnames: ['country', 'city', 'SUM(sales)'],
coltypes: [
GenericDataType.String,
GenericDataType.String,
GenericDataType.Numeric,
],
data: pivotData,
},
];
/**
* Pivot table data with colTotals enabled
*/
const withColTotals = {
...new ChartProps({
...basicChartProps,
formData: {
...basicFormData,
colTotals: true,
rowTotals: true,
rowSubTotals: false,
colSubTotals: false,
},
}),
queriesData: basicQueriesData,
};
/**
* Pivot table data without colTotals
*/
const withoutColTotals = {
...new ChartProps({
...basicChartProps,
formData: {
...basicFormData,
colTotals: false,
rowTotals: false,
rowSubTotals: false,
colSubTotals: false,
},
}),
queriesData: basicQueriesData,
};
/**
* Two-dimension rows data so an outer row-label <th> spans multiple data rows.
* Grouped by [country, city] with "Spain" (last alphabetically) owning two
* cities, so the "Spain" <th> has rowSpan >= 2 and bottoms out the table.
*
* visibleRowKeys order (rowOrder key_a_to_z):
* 0: [France, Paris]
* 1: [Germany, Berlin]
* 2: [Spain, Barcelona]
* 3: [Spain, Madrid]
*/
const groupedRowsData = [
{ country: 'France', city: 'Paris', 'SUM(sales)': 1000 },
{ country: 'Germany', city: 'Berlin', 'SUM(sales)': 2000 },
{ country: 'Spain', city: 'Barcelona', 'SUM(sales)': 1200 },
{ country: 'Spain', city: 'Madrid', 'SUM(sales)': 1500 },
];
const groupedRowsQueriesData = [
{
...basicQueryResult,
colnames: ['country', 'city', 'SUM(sales)'],
coltypes: [
GenericDataType.String,
GenericDataType.String,
GenericDataType.Numeric,
],
data: groupedRowsData,
},
];
const groupedRowsFormData: QueryFormData = {
...basicFormData,
groupbyRows: ['country', 'city'],
groupbyColumns: [],
};
/**
* Two row dimensions, colTotals disabled: the bottom-most spanning <th>
* ("Spain", rowSpan 2) should own the bottom edge.
*/
const groupedRowsWithoutColTotals = {
...new ChartProps({
...basicChartProps,
formData: {
...groupedRowsFormData,
colTotals: false,
rowTotals: false,
rowSubTotals: false,
colSubTotals: false,
},
}),
queriesData: groupedRowsQueriesData,
};
/**
* Two row dimensions, colTotals enabled: the totals row owns the bottom edge,
* so no row-label <th> (spanning or otherwise) gets pvtRowLabelLast.
*/
const groupedRowsWithColTotals = {
...new ChartProps({
...basicChartProps,
formData: {
...groupedRowsFormData,
colTotals: true,
rowTotals: false,
rowSubTotals: false,
colSubTotals: false,
},
}),
queriesData: groupedRowsQueriesData,
};
export default {
withColTotals,
withoutColTotals,
groupedRowsWithoutColTotals,
groupedRowsWithColTotals,
};

View File

@@ -0,0 +1,37 @@
/**
* 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 {
supersetTheme,
ThemeProvider,
EmotionCacheProvider,
createEmotionCache,
} from '@apache-superset/core/theme';
const emotionCache = createEmotionCache({
key: 'test',
});
export function ProviderWrapper(props: any) {
const { children, theme = supersetTheme } = props;
return (
<EmotionCacheProvider value={emotionCache}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</EmotionCacheProvider>
);
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"rootDir": "../../../"
},
"extends": "../../../tsconfig.json",
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
}

View File

@@ -18,8 +18,8 @@
*/
import { FunctionComponent, useState, useRef, ChangeEvent } from 'react';
import SchemaForm, { FormProps } from '@rjsf/core';
import { FormValidation } from '@rjsf/utils';
import SchemaForm from '@rjsf/core';
import { type FormValidation } from '@rjsf/utils';
import validator from '@rjsf/validator-ajv8';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
@@ -74,10 +74,13 @@ const getValidationRules = () => scheduledQueriesConf?.VALIDATION || [];
const getValidator = () => {
const rules: any = getValidationRules();
return (formData: Record<string, any>, errors: FormValidation) => {
return (
formData: Record<string, any> | undefined,
errors: FormValidation,
) => {
rules.forEach((rule: any) => {
const test = validators[rule.name as keyof typeof validators];
const args = rule.arguments.map((name: string) => formData[name]);
const args = rule.arguments.map((name: string) => formData?.[name]);
const container = rule.container || rule.arguments.slice(-1)[0];
if (!test(args[0], args[1])) {
errors[container]?.addError(rule.message);
@@ -160,7 +163,7 @@ const ScheduleQueryButton: FunctionComponent<ScheduleQueryButtonProps> = ({
const onScheduleSubmit = ({
formData,
}: {
formData?: Omit<FormProps<Record<string, any>>, 'schema'>;
formData?: Record<string, any>;
}) => {
const query = {
label,

View File

@@ -18,6 +18,7 @@ from __future__ import annotations
import contextlib
import logging
import re
from datetime import datetime
from typing import Any, Callable, TYPE_CHECKING
@@ -495,13 +496,25 @@ class ChartDataRestApi(ChartRestApi):
result, form_data, filename=filename, expected_rows=expected_rows
)
export_filename = filename or self._get_default_export_filename(form_data)
# `generate_download_headers` always appends the format extension,
# so strip a matching one here to avoid doubled extensions (e.g.
# "chart.csv.csv") if the caller already included it.
export_filename = re.sub(
r"\.(csv|xlsx|zip)$", "", export_filename, flags=re.IGNORECASE
)
if len(result["queries"]) == 1:
# return single query results
data = result["queries"][0]["data"]
if is_csv_format:
return CsvResponse(data, headers=generate_download_headers("csv"))
return CsvResponse(
data, headers=generate_download_headers("csv", export_filename)
)
return XlsxResponse(data, headers=generate_download_headers("xlsx"))
return XlsxResponse(
data, headers=generate_download_headers("xlsx", export_filename)
)
# return multi-query results bundled as a zip file
def _process_data(query_data: Any) -> Any:
@@ -519,7 +532,7 @@ class ChartDataRestApi(ChartRestApi):
}
return Response(
create_zip(files),
headers=generate_download_headers("zip"),
headers=generate_download_headers("zip", export_filename),
mimetype="application/zip",
)
@@ -545,6 +558,26 @@ class ChartDataRestApi(ChartRestApi):
return self.response_400(message=f"Unsupported result_format: {result_format}")
@staticmethod
def _get_default_export_filename(form_data: dict[str, Any] | None) -> str:
"""
Build a fallback export filename (without extension) from the chart's
name so downloaded files are easy to identify, instead of the
generic timestamp-only default used by ``generate_download_headers``.
Used whenever the client hasn't supplied an explicit filename, by
both the streaming and non-streaming chart data export responses.
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
chart_name = "export"
if form_data and form_data.get("slice_name"):
chart_name = form_data["slice_name"]
elif form_data and form_data.get("viz_type"):
chart_name = form_data["viz_type"]
return secure_filename(f"superset_{chart_name}_{timestamp}")
def _log_is_cached(
self,
result: dict[str, Any],
@@ -709,16 +742,7 @@ class ChartDataRestApi(ChartRestApi):
# Use filename from frontend if provided, otherwise generate one
if not filename:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
chart_name = "export"
if form_data and form_data.get("slice_name"):
chart_name = form_data["slice_name"]
elif form_data and form_data.get("viz_type"):
chart_name = form_data["viz_type"]
# Sanitize chart name for filename
filename = secure_filename(f"superset_{chart_name}_{timestamp}.csv")
filename = f"{self._get_default_export_filename(form_data)}.csv"
else:
# Sanitize the client-provided filename before placing it in the
# Content-Disposition header to avoid header/path injection.

View File

@@ -728,15 +728,17 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
"Could not import libraries needed to connect to BigQuery."
)
project: str | None = engine.url.host or None
if credentials_info := engine.dialect.credentials_info:
credentials = service_account.Credentials.from_service_account_info(
credentials_info
)
return bigquery.Client(credentials=credentials)
return bigquery.Client(credentials=credentials, project=project)
try:
credentials = google.auth.default()[0]
return bigquery.Client(credentials=credentials)
return bigquery.Client(credentials=credentials, project=project)
except google.auth.exceptions.DefaultCredentialsError as ex:
raise SupersetDBAPIConnectionError(
"The database credentials could not be found."

View File

@@ -49,7 +49,7 @@ class CrateEngineSpec(BaseEngineSpec):
"drivers": [
{
"name": "crate",
"pypi_package": "crate[sqlalchemy]",
"pypi_package": "sqlalchemy-cratedb",
"connection_string": "crate://{host}:{port}",
"is_recommended": True,
},

View File

@@ -17,7 +17,7 @@
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from flask import Flask, g
@@ -236,3 +236,130 @@ def test_extract_export_filename_preserves_normal_name() -> None:
def test_extract_export_filename_all_special_falls_back_to_none() -> None:
"""A name with no usable characters becomes None (generated downstream)."""
assert _extract_filename("***") is None
def test_send_chart_response_uses_chart_name_for_csv_filename() -> None:
"""
Regression test: the non-streaming CSV export branch of
_send_chart_response must include the chart's name in the
Content-Disposition header, not just a bare timestamp, mirroring the
streaming CSV export path.
"""
from superset.charts.data.api import ChartDataRestApi
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
query_context = MagicMock()
query_context.result_type = ChartDataResultType.FULL
query_context.result_format = ChartDataResultFormat.CSV
result = {
"query_context": query_context,
"queries": [{"data": "col_a,col_b\n1,2\n"}],
}
api = ChartDataRestApi()
with (
patch("superset.charts.data.api.security_manager") as mock_security_manager,
patch("superset.charts.data.api.is_feature_enabled", return_value=False),
):
mock_security_manager.can_access.return_value = True
response = api._send_chart_response(
result, form_data={"slice_name": "My Chart", "row_limit": 10}
)
content_disposition = response.headers["Content-Disposition"]
assert "My_Chart" in content_disposition
def test_send_chart_response_uses_chart_name_for_xlsx_filename() -> None:
"""Same regression as above, for the XLSX export branch."""
from superset.charts.data.api import ChartDataRestApi
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
query_context = MagicMock()
query_context.result_type = ChartDataResultType.FULL
query_context.result_format = ChartDataResultFormat.XLSX
result = {
"query_context": query_context,
"queries": [{"data": b"fake-xlsx-bytes"}],
}
api = ChartDataRestApi()
with (
patch("superset.charts.data.api.security_manager") as mock_security_manager,
patch("superset.charts.data.api.is_feature_enabled", return_value=False),
):
mock_security_manager.can_access.return_value = True
response = api._send_chart_response(
result, form_data={"slice_name": "My Chart", "row_limit": 10}
)
content_disposition = response.headers["Content-Disposition"]
assert "My_Chart" in content_disposition
def test_send_chart_response_uses_chart_name_for_zip_filename() -> None:
"""Same regression as above, for the multi-query zip export branch."""
from superset.charts.data.api import ChartDataRestApi
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
query_context = MagicMock()
query_context.result_type = ChartDataResultType.FULL
query_context.result_format = ChartDataResultFormat.CSV
result = {
"query_context": query_context,
"queries": [
{"data": "col_a\n1\n"},
{"data": "col_a\n2\n"},
],
}
api = ChartDataRestApi()
with (
patch("superset.charts.data.api.security_manager") as mock_security_manager,
patch("superset.charts.data.api.is_feature_enabled", return_value=False),
):
mock_security_manager.can_access.return_value = True
response = api._send_chart_response(
result, form_data={"slice_name": "My Chart", "row_limit": 10}
)
content_disposition = response.headers["Content-Disposition"]
assert "My_Chart" in content_disposition
def test_send_chart_response_does_not_double_extension_for_csv_filename() -> None:
"""
Regression test: a client-supplied filename that already includes the
``.csv`` extension must not be doubled (e.g. ``export.csv.csv``) by the
non-streaming CSV export branch of _send_chart_response.
"""
from superset.charts.data.api import ChartDataRestApi
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
query_context = MagicMock()
query_context.result_type = ChartDataResultType.FULL
query_context.result_format = ChartDataResultFormat.CSV
result = {
"query_context": query_context,
"queries": [{"data": "col_a,col_b\n1,2\n"}],
}
api = ChartDataRestApi()
with (
patch("superset.charts.data.api.security_manager") as mock_security_manager,
patch("superset.charts.data.api.is_feature_enabled", return_value=False),
):
mock_security_manager.can_access.return_value = True
response = api._send_chart_response(
result,
form_data={"row_limit": 10},
filename="my_export.csv",
)
content_disposition = response.headers["Content-Disposition"]
assert "my_export.csv.csv" not in content_disposition
assert "my_export.csv" in content_disposition

View File

@@ -430,6 +430,78 @@ def test_get_default_catalog(mocker: MockerFixture) -> None:
assert BigQueryEngineSpec.get_default_catalog(database) == "project"
@pytest.mark.parametrize(
("sqlalchemy_uri", "schema", "expected_project"),
[
("bigquery://uri-project", None, "uri-project"),
("bigquery:///uri-project", None, "uri-project"),
("bigquery://", "dataset_name", None),
],
)
def test_get_client_resolves_uri_project_with_service_account_credentials(
mocker: MockerFixture,
sqlalchemy_uri: str,
schema: str | None,
expected_project: str | None,
) -> None:
"""Test that service-account clients use the project from the engine URI."""
from superset.db_engine_specs.bigquery import BigQueryEngineSpec
credentials_info = {"project_id": "credential-project"}
credentials = mock.Mock()
engine = mock.MagicMock()
engine.url = BigQueryEngineSpec.adjust_engine_params(
make_url(sqlalchemy_uri), {}, schema=schema
)[0]
engine.dialect.credentials_info = credentials_info
create_credentials = mocker.patch(
"superset.db_engine_specs.bigquery.service_account.Credentials."
"from_service_account_info",
return_value=credentials,
)
client = mocker.patch("superset.db_engine_specs.bigquery.bigquery.Client")
BigQueryEngineSpec._get_client(engine, mock.Mock())
create_credentials.assert_called_once_with(credentials_info)
client.assert_called_once_with(credentials=credentials, project=expected_project)
@pytest.mark.parametrize(
("sqlalchemy_uri", "schema", "expected_project"),
[
("bigquery://uri-project", None, "uri-project"),
("bigquery:///uri-project", None, "uri-project"),
("bigquery://", "dataset_name", None),
],
)
def test_get_client_resolves_uri_project_with_application_default_credentials(
mocker: MockerFixture,
sqlalchemy_uri: str,
schema: str | None,
expected_project: str | None,
) -> None:
"""Test that ADC clients use the project from the engine URI."""
from superset.db_engine_specs.bigquery import BigQueryEngineSpec
credentials = mock.Mock()
engine = mock.MagicMock()
engine.url = BigQueryEngineSpec.adjust_engine_params(
make_url(sqlalchemy_uri), {}, schema=schema
)[0]
engine.dialect.credentials_info = None
get_default_credentials = mocker.patch(
"superset.db_engine_specs.bigquery.google.auth.default",
return_value=(credentials, "credential-project"),
)
client = mocker.patch("superset.db_engine_specs.bigquery.bigquery.Client")
BigQueryEngineSpec._get_client(engine, mock.Mock())
get_default_credentials.assert_called_once_with()
client.assert_called_once_with(credentials=credentials, project=expected_project)
def test_get_time_partition_column_uses_catalog_in_table_reference(
mocker: MockerFixture,
) -> None: