Compare commits

...

19 Commits

Author SHA1 Message Date
Evan Rusackas
bb6e5704b8 address review: clarify drag-drop test removal as TODO breadcrumb 2026-04-25 17:23:46 -07:00
Evan Rusackas
d7225cb79e fix(explore): apply canDrop validator on external drops
Address review feedback on ExploreDndContext.handleDragEnd: the external-
drop branch only checked the droppable's accept types and then invoked
onDrop/onDropValue, never consulting the registered canDrop validator.
This let logically invalid drops (duplicates, disallowed adhoc metrics,
columns rejected by simple-column controls) succeed at runtime even when
the UI showed a "cannot drop" indicator.

DndSelectLabel now exposes its dropValidator (canDrop + canDropValue)
on the useDroppable data, and ExploreDndContext checks it before firing
the drop callbacks. Also rephrase two comments that used the time-
relative word "currently" to satisfy the codebase's evergreen-comments
rule.

Files:
- superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.tsx
- superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:16:17 -07:00
Evan Rusackas
616042dc2b address review: skip sortable drags in DndSelectLabel canDrop 2026-04-22 12:26:11 -07:00
Evan Rusackas
f329ea7056 address review: read drop callbacks from over.data.current so external drops work 2026-04-22 12:25:39 -07:00
Evan Rusackas
67b673db64 fix(tests): restore within import in DndMetricSelect.test.tsx
Accidentally removed the `within` import when cleaning up unused imports,
but it's still used by the warning icon tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:31:10 -07:00
Evan Rusackas
3026ce2c64 fix(tests): remove unused imports after test removal
Removed unused imports (fireEvent, OptionControlLabel, within,
DatasourcePanelDragOption, DndItemType) that were only used in
the skipped tests that were removed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:31:10 -07:00
Evan Rusackas
4bed21d830 fix(tests): remove skipped drag-and-drop tests and fix findByRole
The @dnd-kit library uses pointer events instead of HTML5 drag events,
making the existing drag-and-drop tests incompatible. These tests have
been removed since they cannot work with @dnd-kit's event model.

Also fixed AdhocFilterOption test to use findByTestId instead of
findByRole('button') since @dnd-kit adds additional button elements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:31:10 -07:00
Evan Rusackas
61b47d36a8 test: fix test compatibility for @dnd-kit migration
Update test files to use useDndKit wrapper and skip tests that rely on
HTML5 drag events (dragStart/dragEnd/drop) which don't work with
@dnd-kit's PointerSensor.

- Change useDnd: true to useDndKit: true in all affected tests
- Skip drag-and-drop interaction tests that need PointerSensor mocking
- Skip DashboardWrapper drag test due to react-dnd/@dnd-kit cross-library issue
- Use testId selector instead of role selector for remove buttons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:31:09 -07:00
Evan Rusackas
ad1b4c2368 refactor(explore): migrate Explore Controls from react-dnd to @dnd-kit
Migrate the Explore Controls drag-and-drop functionality from react-dnd
to @dnd-kit for React 18 compatibility. This includes:

- OptionWrapper: sortable column/metric options
- OptionControlLabel: sortable metric labels
- DndSelectLabel: SortableContext wrapper for animations
- DatasourcePanelDragOption: datasource panel drag source
- ExploreDndContext: new DndContext wrapper with drag handlers

Also updates test helpers to support @dnd-kit via `useDndKit: true` option
and updates all related test files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:31:09 -07:00
dependabot[bot]
ffe60bd960 chore(deps-dev): bump oxlint from 1.51.0 to 1.53.0 in /superset-frontend (#38571)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-11 23:41:41 +07:00
dependabot[bot]
d752be5f74 chore(deps): bump dompurify from 3.3.1 to 3.3.2 in /superset-frontend (#38455)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-03-11 08:51:40 -07:00
dependabot[bot]
3056c41507 chore(deps): bump caniuse-lite from 1.0.30001775 to 1.0.30001777 in /docs (#38463)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-03-11 08:51:21 -07:00
dependabot[bot]
d42e9c4d1b chore(deps): bump acorn from 8.9.0 to 8.16.0 in /superset-frontend (#38466)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-03-11 08:51:10 -07:00
dependabot[bot]
5912941942 chore(deps-dev): bump @typescript-eslint/parser from 8.56.1 to 8.57.0 in /superset-websocket (#38570)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-11 22:50:11 +07:00
dependabot[bot]
9b8106b382 chore(deps-dev): bump mini-css-extract-plugin from 2.10.0 to 2.10.1 in /superset-frontend (#38573)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-11 22:23:08 +07:00
amaannawab923
9215eb5e45 fix(ag-grid): persist AG Grid column filters in explore permalinks (#38393) 2026-03-11 01:56:24 +05:30
Amin Ghadersohi
fe7f220c21 fix(charts): set reasonable default y-axis title margin to prevent label overlap (#38389)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:09:09 +01:00
Amin Ghadersohi
3bb9704cd5 fix(mcp): honor target_tab parameter when adding charts to tabbed dashboards (#38409)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:57:15 -07:00
Amin Ghadersohi
eb77452857 feat(mcp): auto-generate dashboard title from chart names when omitted (#38410)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:56:58 -07:00
51 changed files with 1711 additions and 1058 deletions

View File

@@ -70,7 +70,7 @@
"@swc/core": "^1.15.17",
"antd": "^6.3.2",
"baseline-browser-mapping": "^2.10.0",
"caniuse-lite": "^1.0.30001775",
"caniuse-lite": "^1.0.30001777",
"docusaurus-plugin-openapi-docs": "^4.6.0",
"docusaurus-theme-openapi-docs": "^4.6.0",
"js-yaml": "^4.1.1",

View File

@@ -6209,15 +6209,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759:
version "1.0.30001770"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz#4dc47d3b263a50fbb243448034921e0a88591a84"
integrity sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==
caniuse-lite@^1.0.30001775:
version "1.0.30001775"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz#9572266e3f7f77efee5deac1efeb4795879d1b7f"
integrity sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001777:
version "1.0.30001777"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz#028f21e4b2718d138b55e692583e6810ccf60691"
integrity sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==
ccount@^2.0.0:
version "2.0.1"

View File

@@ -262,9 +262,9 @@
"jsdom": "^28.1.0",
"lerna": "^8.2.3",
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.0",
"mini-css-extract-plugin": "^2.10.1",
"open-cli": "^8.0.0",
"oxlint": "^1.51.0",
"oxlint": "^1.53.0",
"po2json": "^0.4.5",
"prettier": "3.8.1",
"prettier-plugin-packagejson": "^3.0.2",
@@ -8563,9 +8563,9 @@
}
},
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz",
"integrity": "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.53.0.tgz",
"integrity": "sha512-JC89/jAx4d2zhDIbK8MC4L659FN1WiMXMBkNg7b33KXSkYpUgcbf+0nz7+EPRg+VwWiZVfaoFkNHJ7RXYb5Neg==",
"cpu": [
"arm"
],
@@ -8580,9 +8580,9 @@
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.51.0.tgz",
"integrity": "sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.53.0.tgz",
"integrity": "sha512-CY+pZfi+uyeU7AwFrEnjsNT+VfxYmKLMuk7bVxArd8f+09hQbJb8f7C7EpvTfNqrCK1J8zZlaYI4LltmEctgbQ==",
"cpu": [
"arm64"
],
@@ -8597,9 +8597,9 @@
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.51.0.tgz",
"integrity": "sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.53.0.tgz",
"integrity": "sha512-0aqsC4HDQ94oI6kMz64iaOJ1f3bCVArxvaHJGOScBvFz6CcQedXi5b70Xg09CYjKNaHA56dW0QJfoZ/111kz1A==",
"cpu": [
"arm64"
],
@@ -8614,9 +8614,9 @@
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.51.0.tgz",
"integrity": "sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.53.0.tgz",
"integrity": "sha512-e+KvuaWtnisyWojO/t5qKDbp2dvVpg+1dl4MGnTb21QpY4+4+9Y1XmZPaztcA2XNvy4BIaXFW+9JH9tMpSBqUg==",
"cpu": [
"x64"
],
@@ -8631,9 +8631,9 @@
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.51.0.tgz",
"integrity": "sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.53.0.tgz",
"integrity": "sha512-hpU0ZHVeblFjmZDfgi9BxhhCpURh0KjoFy5V+Tvp9sg/fRcnMUEfaJrgz+jQfOX4jctlVWrAs1ANs91+5iV+zA==",
"cpu": [
"x64"
],
@@ -8648,9 +8648,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.51.0.tgz",
"integrity": "sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.53.0.tgz",
"integrity": "sha512-ccKxOpw+X4xa2pO+qbTOpxQ2x1+Ag3ViRQMnWt3gHp1LcpNgS1xd6GYc3OvehmHtrXqEV3YGczZ0I1qpBB4/2A==",
"cpu": [
"arm"
],
@@ -8665,9 +8665,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.51.0.tgz",
"integrity": "sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.53.0.tgz",
"integrity": "sha512-UBkBvmzSmlyH2ZObQMDKW/TuyTmUtP/XClPUyU2YLwj0qLopZTZxnDz4VG5d3wz1HQuZXO0o1QqsnQUW1v4a6Q==",
"cpu": [
"arm"
],
@@ -8682,9 +8682,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.51.0.tgz",
"integrity": "sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.53.0.tgz",
"integrity": "sha512-PQJJ1izoH9p61las6rZ0BWOznAhTDMmdUPL2IEBLuXFwhy2mSloYHvRkk39PSYJ1DyG+trqU5Z9ZbtHSGH6plg==",
"cpu": [
"arm64"
],
@@ -8699,9 +8699,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.51.0.tgz",
"integrity": "sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.53.0.tgz",
"integrity": "sha512-GXI1o4Thn/rtnRIL38BwrDMwVcUbIHKCsOixIWf/CkU3fCG3MXFzFTtDMt+34ik0Qk452d8kcpksL0w/hUkMZA==",
"cpu": [
"arm64"
],
@@ -8716,9 +8716,9 @@
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.51.0.tgz",
"integrity": "sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.53.0.tgz",
"integrity": "sha512-Uahk7IVs2yBamCgeJ3XKpKT9Vh+de0pDKISFKnjEcI3c/w2CFHk1+W6Q6G3KI56HGwE9PWCp6ayhA9whXWkNIQ==",
"cpu": [
"ppc64"
],
@@ -8733,9 +8733,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.51.0.tgz",
"integrity": "sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.53.0.tgz",
"integrity": "sha512-sWtcU9UkrKMWsGKdFy8R6jkm9Q0VVG1VCpxVuh0HzRQQi3ENI1Nh5CkpsdfUs2MKRcOoHKbXqTscunuXjhxoxQ==",
"cpu": [
"riscv64"
],
@@ -8750,9 +8750,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.51.0.tgz",
"integrity": "sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.53.0.tgz",
"integrity": "sha512-aXew1+HDvCdExijX/8NBVC854zJwxhKP3l9AHFSHQNo4EanlHtzDMIlIvP3raUkL0vXtFCkTFYezzU5HjstB8A==",
"cpu": [
"riscv64"
],
@@ -8767,9 +8767,9 @@
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.51.0.tgz",
"integrity": "sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.53.0.tgz",
"integrity": "sha512-rVpyBSqPGou9sITcsoXqUoGBUH74bxYLYOAGUqN599Zu6BQBlBU9hh3bJQ/20D1xrhhrsbiCpVPvXpLPM5nL1w==",
"cpu": [
"s390x"
],
@@ -8784,9 +8784,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.51.0.tgz",
"integrity": "sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.53.0.tgz",
"integrity": "sha512-eOyeQ8qFQ2geXmlWJuXAOaek0hFhbMLlYsU457NMLKDRoC43Xf+eDPZ9Yk0n9jDaGJ5zBl/3Dy8wo41cnIXuLA==",
"cpu": [
"x64"
],
@@ -8801,9 +8801,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.51.0.tgz",
"integrity": "sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.53.0.tgz",
"integrity": "sha512-S6rBArW/zD1tob8M9PwKYrRmz+j1ss1+wjbRAJCWKd7TC3JB6noDiA95pIj9zOZVVp04MIzy5qymnYusrEyXzg==",
"cpu": [
"x64"
],
@@ -8818,9 +8818,9 @@
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.51.0.tgz",
"integrity": "sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.53.0.tgz",
"integrity": "sha512-sd/A0Ny5sN0D/MJtlk7w2jGY4bJQou7gToa9WZF7Sj6HTyVzvlzKJWiOHfr4SulVk4ndiFQ8rKmF9rXP0EcF3A==",
"cpu": [
"arm64"
],
@@ -8835,9 +8835,9 @@
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.51.0.tgz",
"integrity": "sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.53.0.tgz",
"integrity": "sha512-QC3q7b51Er/ZurEFcFzc7RpQ/YEoEBLJuCp3WoOzhSHHH/nkUKFy+igOxlj1z3LayhEZPDQQ7sXvv2PM2cdG3Q==",
"cpu": [
"arm64"
],
@@ -8852,9 +8852,9 @@
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.51.0.tgz",
"integrity": "sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.53.0.tgz",
"integrity": "sha512-3OvLgOqwd705hWHV2i8ni80pilvg6BUgpC2+xtVu++e/q28LKVohGh5J5QYJOrRMfWmxK0M/AUu43vUw62LAKQ==",
"cpu": [
"ia32"
],
@@ -8869,9 +8869,9 @@
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.51.0.tgz",
"integrity": "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.53.0.tgz",
"integrity": "sha512-xTiOkntexCdJytZ7ArIIgl3vGW5ujMM3sJNM7/+iqGAVJagCqjFFWn68HRWRLeyT66c95uR+CeFmQFI6mLQqDw==",
"cpu": [
"x64"
],
@@ -23129,6 +23129,7 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -36954,9 +36955,9 @@
}
},
"node_modules/mini-css-extract-plugin": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz",
"integrity": "sha512-540P2c5dYnJlyJxTaSloliZexv8rji6rY8FhQN+WF/82iHQfA23j/xtJx97L+mXOML27EqksSek/g4eK7jaL3g==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.1.tgz",
"integrity": "sha512-k7G3Y5QOegl380tXmZ68foBRRjE9Ljavx835ObdvmZjQ639izvZD8CS7BkWw1qKPPzHsGL/JDhl0uyU1zc2rJw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -38781,9 +38782,9 @@
}
},
"node_modules/oxlint": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.51.0.tgz",
"integrity": "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==",
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.53.0.tgz",
"integrity": "sha512-TLW0PzGbpO1JxUnuy1pIqVPjQUGh4fNfxu5XJbdFIRFVaJ0UFzTjjk/hSFTMRxN6lZub53xL/IwJNEkrh7VtDg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -38796,25 +38797,25 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.51.0",
"@oxlint/binding-android-arm64": "1.51.0",
"@oxlint/binding-darwin-arm64": "1.51.0",
"@oxlint/binding-darwin-x64": "1.51.0",
"@oxlint/binding-freebsd-x64": "1.51.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.51.0",
"@oxlint/binding-linux-arm-musleabihf": "1.51.0",
"@oxlint/binding-linux-arm64-gnu": "1.51.0",
"@oxlint/binding-linux-arm64-musl": "1.51.0",
"@oxlint/binding-linux-ppc64-gnu": "1.51.0",
"@oxlint/binding-linux-riscv64-gnu": "1.51.0",
"@oxlint/binding-linux-riscv64-musl": "1.51.0",
"@oxlint/binding-linux-s390x-gnu": "1.51.0",
"@oxlint/binding-linux-x64-gnu": "1.51.0",
"@oxlint/binding-linux-x64-musl": "1.51.0",
"@oxlint/binding-openharmony-arm64": "1.51.0",
"@oxlint/binding-win32-arm64-msvc": "1.51.0",
"@oxlint/binding-win32-ia32-msvc": "1.51.0",
"@oxlint/binding-win32-x64-msvc": "1.51.0"
"@oxlint/binding-android-arm-eabi": "1.53.0",
"@oxlint/binding-android-arm64": "1.53.0",
"@oxlint/binding-darwin-arm64": "1.53.0",
"@oxlint/binding-darwin-x64": "1.53.0",
"@oxlint/binding-freebsd-x64": "1.53.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.53.0",
"@oxlint/binding-linux-arm-musleabihf": "1.53.0",
"@oxlint/binding-linux-arm64-gnu": "1.53.0",
"@oxlint/binding-linux-arm64-musl": "1.53.0",
"@oxlint/binding-linux-ppc64-gnu": "1.53.0",
"@oxlint/binding-linux-riscv64-gnu": "1.53.0",
"@oxlint/binding-linux-riscv64-musl": "1.53.0",
"@oxlint/binding-linux-s390x-gnu": "1.53.0",
"@oxlint/binding-linux-x64-gnu": "1.53.0",
"@oxlint/binding-linux-x64-musl": "1.53.0",
"@oxlint/binding-openharmony-arm64": "1.53.0",
"@oxlint/binding-win32-arm64-msvc": "1.53.0",
"@oxlint/binding-win32-ia32-msvc": "1.53.0",
"@oxlint/binding-win32-x64-msvc": "1.53.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=0.15.0"
@@ -51491,7 +51492,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"dompurify": "^3.3.2",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.8",
"jed": "^1.1.1",
@@ -51505,7 +51506,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
@@ -51593,6 +51594,18 @@
"node": ">=12"
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"packages/superset-ui-core/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
@@ -52849,7 +52862,7 @@
"dependencies": {
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"dompurify": "^3.3.1",
"dompurify": "^3.3.2",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.17.23",
"nvd3-fork": "^2.0.5",
@@ -52864,6 +52877,18 @@
"react": "^17.0.2"
}
},
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"plugins/plugin-chart-ag-grid-table": {
"name": "@superset-ui/plugin-chart-ag-grid-table",
"version": "0.20.3",
@@ -52941,7 +52966,7 @@
"dependencies": {
"@types/d3-array": "^3.2.2",
"@types/react-redux": "^7.1.34",
"acorn": "^8.9.0",
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.17.23",
"zod": "^4.3.6"
@@ -52957,9 +52982,9 @@
}
},
"plugins/plugin-chart-echarts/node_modules/acorn": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz",
"integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"

View File

@@ -343,9 +343,9 @@
"jsdom": "^28.1.0",
"lerna": "^8.2.3",
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.0",
"mini-css-extract-plugin": "^2.10.1",
"open-cli": "^8.0.0",
"oxlint": "^1.51.0",
"oxlint": "^1.53.0",
"po2json": "^0.4.5",
"prettier": "3.8.1",
"prettier-plugin-packagejson": "^3.0.2",

View File

@@ -41,7 +41,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dompurify": "^3.3.1",
"dompurify": "^3.3.2",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.8",
"jed": "^1.1.1",

View File

@@ -34,7 +34,7 @@
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.17.23",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.3.1",
"dompurify": "^3.3.2",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},

View File

@@ -26,7 +26,7 @@
"dependencies": {
"@types/d3-array": "^3.2.2",
"@types/react-redux": "^7.1.34",
"acorn": "^8.9.0",
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.17.23",
"zod": "^4.3.6"

View File

@@ -158,7 +158,7 @@ const defaultFormData: EchartsTimeseriesFormData & {
xAxisTitle: '',
xAxisTitleMargin: 0,
yAxisTitle: '',
yAxisTitleMargin: 0,
yAxisTitleMargin: 15,
yAxisTitlePosition: '',
time_range: 'No filter',
granularity: undefined,

View File

@@ -46,7 +46,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
xAxisTitle: '',
xAxisTitleMargin: 0,
yAxisTitle: '',
yAxisTitleMargin: 0,
yAxisTitleMargin: 15,
yAxisTitlePosition: 'Top',
// Now that the weird bug workaround is over, here's the rest...
...DEFAULT_SORT_SERIES_DATA,

View File

@@ -104,7 +104,7 @@ export const DEFAULT_TITLE_FORM_DATA: TitleFormData = {
xAxisTitle: '',
xAxisTitleMargin: 0,
yAxisTitle: '',
yAxisTitleMargin: 0,
yAxisTitleMargin: 15,
yAxisTitlePosition: 'Top',
};

View File

@@ -112,7 +112,7 @@ const formData: EchartsMixedTimeseriesFormData = {
yAxisBounds: [undefined, undefined],
yAxisBoundsSecondary: [undefined, undefined],
yAxisTitle: '',
yAxisTitleMargin: 0,
yAxisTitleMargin: 15,
yAxisTitlePosition: '',
yAxisTitleSecondary: '',
zoomable: false,

View File

@@ -37,6 +37,7 @@ import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DndContext } from '@dnd-kit/core';
import reducerIndex from 'spec/helpers/reducerIndex';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -47,6 +48,7 @@ import userEvent from '@testing-library/user-event';
type Options = Omit<RenderOptions, 'queries'> & {
useRedux?: boolean;
useDnd?: boolean;
useDndKit?: boolean; // Use @dnd-kit instead of react-dnd
useQueryParams?: boolean;
useRouter?: boolean;
useTheme?: boolean;
@@ -74,6 +76,7 @@ export const defaultStore = createStore();
export function createWrapper(options?: Options) {
const {
useDnd,
useDndKit,
useRedux,
useQueryParams,
useRouter,
@@ -96,6 +99,10 @@ export function createWrapper(options?: Options) {
);
}
if (useDndKit) {
result = <DndContext>{result}</DndContext>;
}
if (useDnd) {
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
}

View File

@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render } from 'spec/helpers/testing-library';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import { render } from 'spec/helpers/testing-library';
import DashboardWrapper from './DashboardWrapper';
@@ -39,50 +38,6 @@ test('should render children', () => {
expect(getByTestId('mock-children')).toBeInTheDocument();
});
test('should update the style on dragging state', async () => {
const defaultProps = {
label: <span>Test label</span>,
tooltipTitle: 'This is a tooltip title',
onRemove: jest.fn(),
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
type: 'test',
index: 0,
};
const { container, getByText } = render(
<DashboardWrapper>
<OptionControlLabel
{...defaultProps}
index={1}
label={<span>Label 1</span>}
/>
<OptionControlLabel
{...defaultProps}
index={2}
label={<span>Label 2</span>}
/>
</DashboardWrapper>,
{
useRedux: true,
useDnd: true,
initialState: {
dashboardState: {
editMode: true,
},
},
},
);
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(0);
fireEvent.dragStart(getByText('Label 1'));
jest.runAllTimers();
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(1);
fireEvent.dragEnd(getByText('Label 1'));
// immediately discards dragging state after dragEnd
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(0);
});
// Note: Drag-and-drop test removed - DashboardWrapper uses react-dnd but
// OptionControlLabel uses @dnd-kit, causing cross-library compatibility issues.
// This test requires proper @dnd-kit testing utilities.

View File

@@ -36,6 +36,10 @@ import {
isChartCustomization,
} from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import {
HYDRATE_EXPLORE,
HydrateExplore,
} from 'src/explore/actions/hydrateExplore';
import { SaveFilterChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
import {
migrateChartCustomizationArray,
@@ -195,7 +199,7 @@ function updateDataMaskForFilterChanges(
const dataMaskReducer = produce(
(
draft: DataMaskStateWithId,
action: AnyDataMaskAction | HydrateDashboardAction,
action: AnyDataMaskAction | HydrateDashboardAction | HydrateExplore,
) => {
const cleanState: DataMaskStateWithId = {};
switch (action.type) {
@@ -286,6 +290,20 @@ const dataMaskReducer = produce(
return cleanState;
}
case HYDRATE_EXPLORE: {
const hydrateExploreAction = action as HydrateExplore;
const loadedDataMask = hydrateExploreAction.data.dataMask;
if (loadedDataMask) {
Object.entries(loadedDataMask).forEach(([id, mask]) => {
draft[id] = {
...getInitialDataMask(id),
...draft[id],
...mask,
};
});
}
return draft;
}
case SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE:
updateDataMaskForFilterChanges(
action.filterChanges,

View File

@@ -153,6 +153,19 @@ export function setForceQuery(force: boolean) {
};
}
export const UPDATE_EXPLORE_CHART_STATE = 'UPDATE_EXPLORE_CHART_STATE';
export function updateExploreChartState(
chartId: number,
chartState: Record<string, unknown>,
) {
return {
type: UPDATE_EXPLORE_CHART_STATE,
chartId,
chartState,
lastModified: Date.now(),
};
}
export const SET_STASH_FORM_DATA = 'SET_STASH_FORM_DATA';
export function setStashFormData(
isHidden: boolean,

View File

@@ -28,6 +28,8 @@ import { getControlsState } from 'src/explore/store';
import { Dispatch } from 'redux';
import {
Currency,
DataMaskStateWithId,
JsonObject,
ensureIsArray,
FeatureFlag,
getCategoricalSchemeRegistry,
@@ -60,7 +62,12 @@ export const hydrateExplore =
dataset,
metadata,
saveAction = null,
}: ExplorePageInitialData) =>
dataMask,
chartStates,
}: ExplorePageInitialData & {
dataMask?: DataMaskStateWithId;
chartStates?: Record<number, JsonObject>;
}) =>
(dispatch: Dispatch, getState: () => ExplorePageState) => {
const { user, datasources, charts, sliceEntities, common, explore } =
getState();
@@ -224,12 +231,13 @@ export const hydrateExplore =
saveModalAlert: null,
isVisible: false,
},
explore: exploreState,
explore: { ...exploreState, chartStates },
dataMask,
},
});
};
export type HydrateExplore = {
type: typeof HYDRATE_EXPLORE;
data: ExplorePageState;
data: ExplorePageState & { dataMask?: DataMaskStateWithId };
};

View File

@@ -26,7 +26,7 @@ test('should render', async () => {
value={{ metric_name: 'test', uuid: '1' }}
type={DndItemType.Metric}
/>,
{ useDnd: true },
{ useDndKit: true },
);
expect(
@@ -34,17 +34,3 @@ test('should render', async () => {
).toBeInTheDocument();
expect(screen.getByText('test')).toBeInTheDocument();
});
test('should have attribute draggable:true', async () => {
render(
<DatasourcePanelDragOption
value={{ metric_name: 'test', uuid: '1' }}
type={DndItemType.Metric}
/>,
{ useDnd: true },
);
expect(
await screen.findByTestId('DatasourcePanelDragOption'),
).toHaveAttribute('draggable', 'true');
});

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { RefObject } from 'react';
import { useDrag } from 'react-dnd';
import { RefObject, useMemo } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Metric } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { ColumnMeta } from '@superset-ui/chart-controls';
@@ -30,8 +30,8 @@ import { Icons } from '@superset-ui/core/components/Icons';
import { DatasourcePanelDndItem } from '../types';
const DatasourceItemContainer = styled.div`
${({ theme }) => css`
const DatasourceItemContainer = styled.div<{ isDragging?: boolean }>`
${({ theme, isDragging }) => css`
display: flex;
align-items: center;
justify-content: space-between;
@@ -44,6 +44,8 @@ const DatasourceItemContainer = styled.div`
color: ${theme.colorText};
background-color: ${theme.colorBgLayout};
border-radius: 4px;
cursor: ${isDragging ? 'grabbing' : 'grab'};
opacity: ${isDragging ? 0.5 : 1};
&:hover {
background-color: ${theme.colorPrimaryBgHover};
@@ -70,14 +72,23 @@ export default function DatasourcePanelDragOption(
) {
const { labelRef, showTooltip, type, value } = props;
const theme = useTheme();
const [{ isDragging }, drag] = useDrag({
item: {
value: props.value,
type: props.type,
// Create a unique ID for this draggable item
const draggableId = useMemo(() => {
if (type === DndItemType.Column) {
const col = value as ColumnMeta;
return `datasource-${type}-${col.column_name || col.verbose_name}`;
}
const metric = value as MetricOption;
return `datasource-${type}-${metric.metric_name || metric.label}`;
}, [type, value]);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: draggableId,
data: {
type,
value,
},
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
});
const optionProps = {
@@ -87,7 +98,13 @@ export default function DatasourcePanelDragOption(
};
return (
<DatasourceItemContainer data-test="DatasourcePanelDragOption" ref={drag}>
<DatasourceItemContainer
data-test="DatasourcePanelDragOption"
ref={setNodeRef}
isDragging={isDragging}
{...attributes}
{...listeners}
>
{type === DndItemType.Column ? (
<StyledColumnOption column={value as ColumnMeta} {...optionProps} />
) : (

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Split from 'react-split';
import { t } from '@apache-superset/core/translation';
import {
@@ -33,6 +34,11 @@ import {
import { Alert } from '@apache-superset/core/components';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import ChartContainer from 'src/components/Chart/ChartContainer';
import { updateExploreChartState } from 'src/explore/actions/exploreActions';
import {
convertChartStateToOwnState,
hasChartStateConverter,
} from 'src/dashboard/util/chartStateConverter';
import {
getItem,
setItem,
@@ -43,6 +49,7 @@ import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import { buildV1ChartDataPayload } from 'src/explore/exploreUtils';
import { getChartRequiredFieldsMissingMessage } from 'src/utils/getChartRequiredFieldsMissingMessage';
import type { ChartState, Datasource } from 'src/explore/types';
import type { ExploreState } from 'src/explore/reducers/exploreReducer';
import type { Slice } from 'src/types/Chart';
import LastQueriedLabel from 'src/components/LastQueriedLabel';
import { DataTablesPane } from '../DataTablesPane';
@@ -126,6 +133,28 @@ const Styles = styled.div<{ showSplite: boolean }>`
}
`;
const EMPTY_OBJECT: Record<string, never> = {};
const createOwnStateWithChartState = (
baseOwnState: JsonObject,
chartState: { state?: JsonObject } | undefined,
vizTypeArg: string,
): JsonObject => {
if (!hasChartStateConverter(vizTypeArg)) {
return baseOwnState;
}
const state = chartState?.state;
if (!state) {
return baseOwnState;
}
const convertedState = convertChartStateToOwnState(vizTypeArg, state);
return {
...baseOwnState,
...convertedState,
chartState: state,
};
};
const ExploreChartPanel = ({
chart,
slice,
@@ -145,8 +174,34 @@ const ExploreChartPanel = ({
can_download: canDownload,
}: ExploreChartPanelProps) => {
const theme = useTheme();
const dispatch = useDispatch();
const gutterMargin = theme.sizeUnit * GUTTER_SIZE_FACTOR;
const gutterHeight = theme.sizeUnit * GUTTER_SIZE_FACTOR;
const chartState = useSelector(
(state: { explore?: ExploreState }) =>
state.explore?.chartStates?.[chart.id],
);
const handleChartStateChange = useCallback(
(chartStateArg: JsonObject) => {
if (hasChartStateConverter(vizType)) {
dispatch(updateExploreChartState(chart.id, chartStateArg));
}
},
[dispatch, chart.id, vizType],
);
const mergedOwnState = useMemo(
() =>
createOwnStateWithChartState(
ownState || EMPTY_OBJECT,
chartState as { state?: JsonObject } | undefined,
vizType,
),
[ownState, chartState, vizType],
);
const {
ref: chartPanelRef,
observerRef: resizeObserverRef,
@@ -259,7 +314,7 @@ const ExploreChartPanel = ({
<ChartContainer
width={Math.floor(chartPanelWidth)}
height={chartPanelHeight}
ownState={ownState}
ownState={mergedOwnState}
annotationData={chart.annotationData}
chartId={chart.id}
triggerRender={triggerRender}
@@ -277,6 +332,7 @@ const ExploreChartPanel = ({
timeout={timeout}
triggerQuery={chart.triggerQuery}
vizType={vizType}
onChartStateChange={handleChartStateChange}
{...(chart.chartAlert && { chartAlert: chart.chartAlert })}
{...(chart.chartStackTrace && {
chartStackTrace: chart.chartStackTrace,
@@ -304,8 +360,9 @@ const ExploreChartPanel = ({
errorMessage,
force,
formData,
handleChartStateChange,
onQuery,
ownState,
mergedOwnState,
timeout,
triggerRender,
vizType,

View File

@@ -18,12 +18,8 @@
*/
import { useContext } from 'react';
import { fireEvent, render } from 'spec/helpers/testing-library';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import ExploreContainer, { DraggingContext, DropzoneContext } from '.';
import OptionWrapper from '../controls/DndColumnSelectControl/OptionWrapper';
import DatasourcePanelDragOption from '../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../DndItemType';
const MockChildren = () => {
const dragging = useContext(DraggingContext);
@@ -57,58 +53,21 @@ test('should render children', () => {
<ExploreContainer>
<MockChildren />
</ExploreContainer>,
{ useRedux: true, useDnd: true },
{ useRedux: true },
);
expect(getByTestId('mock-children')).toBeInTheDocument();
expect(getByText('not dragging')).toBeInTheDocument();
});
test('should only propagate dragging state when dragging the panel option', () => {
const defaultProps = {
label: <span>Test label</span>,
tooltipTitle: 'This is a tooltip title',
onRemove: jest.fn(),
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
type: 'test',
index: 0,
};
test('should initially have dragging set to false', () => {
const { container, getByText } = render(
<ExploreContainer>
<DatasourcePanelDragOption
value={{ metric_name: 'panel option', uuid: '1' }}
type={DndItemType.Metric}
/>
<OptionControlLabel
{...defaultProps}
index={1}
label={<span>Metric item</span>}
/>
<OptionWrapper
{...defaultProps}
index={2}
label="Column item"
clickClose={() => {}}
onShiftOptions={() => {}}
/>
<MockChildren />
</ExploreContainer>,
{
useRedux: true,
useDnd: true,
},
{ useRedux: true },
);
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
fireEvent.dragStart(getByText('panel option'));
expect(container.getElementsByClassName('dragging')).toHaveLength(1);
fireEvent.dragEnd(getByText('panel option'));
fireEvent.dragStart(getByText('Metric item'));
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
fireEvent.dragEnd(getByText('Metric item'));
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
// don't show dragging state for the sorting item
fireEvent.dragStart(getByText('Column item'));
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
expect(getByText('not dragging')).toBeInTheDocument();
});
test('should manage the dropValidators', () => {
@@ -116,10 +75,7 @@ test('should manage the dropValidators', () => {
<ExploreContainer>
<MockChildren2 />
</ExploreContainer>,
{
useRedux: true,
useDnd: true,
},
{ useRedux: true },
);
expect(queryByText('test_item_1')).not.toBeInTheDocument();

View File

@@ -0,0 +1,293 @@
/**
* 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 {
createContext,
useContext,
useState,
useCallback,
useMemo,
FC,
Dispatch,
useReducer,
} from 'react';
import {
DndContext,
useSensor,
useSensors,
PointerSensor,
DragStartEvent,
DragEndEvent,
UniqueIdentifier,
} from '@dnd-kit/core';
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
/**
* Type for the active drag item data
*/
export interface ActiveDragData {
type: string;
value?: unknown;
dragIndex?: number;
// For sortable items - callback to handle reorder
onShiftOptions?: (dragIndex: number, hoverIndex: number) => void;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
}
/**
* Context to track if something is being dragged (for visual feedback)
*/
export const DraggingContext = createContext(false);
/**
* Context exposing the active drag item, if any
*/
export const ActiveDragContext = createContext<ActiveDragData | null>(null);
/**
* Dropzone validation - used by controls to register what they can accept
*/
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
type DropzoneSet = Record<string, CanDropValidator>;
type Action = { key: string; canDrop?: CanDropValidator };
export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
{},
() => {},
]);
const dropzoneReducer = (state: DropzoneSet = {}, action: Action) => {
if (action.canDrop) {
return {
...state,
[action.key]: action.canDrop,
};
}
if (action.key) {
const newState = { ...state };
delete newState[action.key];
return newState;
}
return state;
};
/**
* Context for handling drag end events - controls register their onDrop handlers
*/
type DropHandler = (
activeId: UniqueIdentifier,
overId: UniqueIdentifier,
activeData: ActiveDragData,
) => void;
type DropHandlerSet = Record<string, DropHandler>;
export const DropHandlersContext = createContext<{
register: (id: string, handler: DropHandler) => void;
unregister: (id: string) => void;
}>({
register: () => {},
unregister: () => {},
});
interface ExploreDndContextProps {
children: React.ReactNode;
}
/**
* DnD context provider for the Explore view.
* Wraps @dnd-kit/core's DndContext and provides:
* - Dragging state tracking (for visual feedback)
* - Dropzone registration (for validation)
* - Drop handler registration (for handling drops)
*/
export const ExploreDndContextProvider: FC<ExploreDndContextProps> = ({
children,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [activeData, setActiveData] = useState<ActiveDragData | null>(null);
const [dropHandlers, setDropHandlers] = useState<DropHandlerSet>({});
const dropzoneValue = useReducer(dropzoneReducer, {});
// Configure sensors for drag detection
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 5px movement required before drag starts
},
}),
);
const handleDragStart = useCallback((event: DragStartEvent) => {
const { active } = event;
const data = active.data.current as ActiveDragData | undefined;
// Don't set dragging state for reordering within a list
if (data && 'dragIndex' in data) {
return;
}
setIsDragging(true);
setActiveData(data || null);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setIsDragging(false);
setActiveData(null);
if (over && active.id !== over.id) {
const activeDataCurrent = active.data.current as
| ActiveDragData
| undefined;
const overDataCurrent = over.data.current as ActiveDragData | undefined;
// Check if this is a sortable reorder operation
// Both items need dragIndex and the same type
if (
activeDataCurrent &&
overDataCurrent &&
typeof activeDataCurrent.dragIndex === 'number' &&
typeof overDataCurrent.dragIndex === 'number' &&
activeDataCurrent.type === overDataCurrent.type
) {
const { dragIndex } = activeDataCurrent;
const hoverIndex = overDataCurrent.dragIndex;
// Call the appropriate reorder callback
const reorderCallback =
activeDataCurrent.onShiftOptions || activeDataCurrent.onMoveLabel;
if (reorderCallback) {
reorderCallback(dragIndex, hoverIndex);
}
// Call onDropLabel if provided (for finalization after reorder)
activeDataCurrent.onDropLabel?.();
return;
}
// Handle external drop (from DatasourcePanel to dropzone).
// Droppable zones (e.g., DndSelectLabel) register their drop callbacks
// via `data` on useDroppable, which surfaces here as `over.data.current`.
// Prefer that inline metadata; fall back to the registered handler map
// for any consumers that don't attach data to their droppable.
const droppableData = over.data.current as
| {
accept?: string[];
onDrop?: (item: { type: string; value?: unknown }) => void;
onDropValue?: (value: unknown) => void;
canDrop?: (item: DatasourcePanelDndItem) => boolean;
}
| undefined;
if (activeDataCurrent && droppableData) {
const { accept, onDrop, onDropValue, canDrop } = droppableData;
const typeAccepted =
!accept || accept.includes(activeDataCurrent.type);
if (typeAccepted && (onDrop || onDropValue)) {
const item = {
type: activeDataCurrent.type,
value: activeDataCurrent.value,
};
// Apply the droppable's canDrop validator (e.g., duplicate or
// disallow_adhoc_metrics checks) so the runtime drop behavior
// matches the visual "cannot drop" feedback. Skip the drop
// entirely when the validator rejects the item.
if (canDrop && !canDrop(item as DatasourcePanelDndItem)) {
return;
}
onDrop?.(item);
if (activeDataCurrent.value !== undefined) {
onDropValue?.(activeDataCurrent.value);
}
return;
}
}
const overId = String(over.id);
const handler = dropHandlers[overId];
if (handler && activeDataCurrent) {
handler(active.id, over.id, activeDataCurrent);
}
}
},
[dropHandlers],
);
const handleDragCancel = useCallback(() => {
setIsDragging(false);
setActiveData(null);
}, []);
const registerDropHandler = useCallback(
(id: string, handler: DropHandler) => {
setDropHandlers(prev => ({ ...prev, [id]: handler }));
},
[],
);
const unregisterDropHandler = useCallback((id: string) => {
setDropHandlers(prev => {
const newHandlers = { ...prev };
delete newHandlers[id];
return newHandlers;
});
}, []);
const dropHandlersContextValue = useMemo(
() => ({
register: registerDropHandler,
unregister: unregisterDropHandler,
}),
[registerDropHandler, unregisterDropHandler],
);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<DropzoneContext.Provider value={dropzoneValue}>
<DropHandlersContext.Provider value={dropHandlersContextValue}>
<DraggingContext.Provider value={isDragging}>
<ActiveDragContext.Provider value={activeData}>
{children}
</ActiveDragContext.Provider>
</DraggingContext.Provider>
</DropHandlersContext.Provider>
</DropzoneContext.Provider>
</DndContext>
);
};
/**
* Hook reporting whether a drag is in progress
*/
export const useIsDragging = () => useContext(DraggingContext);
/**
* Hook to get the active drag data
*/
export const useActiveDrag = () => useContext(ActiveDragContext);

View File

@@ -16,28 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
createContext,
useEffect,
useState,
Dispatch,
FC,
useReducer,
} from 'react';
import { FC } from 'react';
import { styled } from '@apache-superset/core/theme';
import { useDragDropManager } from 'react-dnd';
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
import {
ExploreDndContextProvider,
DraggingContext,
DropzoneContext,
} from './ExploreDndContext';
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
type DropzoneSet = Record<string, CanDropValidator>;
type Action = { key: string; canDrop?: CanDropValidator };
// Re-export contexts for backward compatibility
export { DraggingContext, DropzoneContext };
export const DraggingContext = createContext(false);
export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
{},
() => {},
]);
const StyledDiv = styled.div`
display: flex;
flex-direction: column;
@@ -45,53 +34,10 @@ const StyledDiv = styled.div`
min-height: 0;
`;
const reducer = (state: DropzoneSet = {}, action: Action) => {
if (action.canDrop) {
return {
...state,
[action.key]: action.canDrop,
};
}
if (action.key) {
const newState = { ...state };
delete newState[action.key];
return newState;
}
return state;
};
const ExploreContainer: FC<{}> = ({ children }) => {
const dragDropManager = useDragDropManager();
const [dragging, setDragging] = useState(
dragDropManager.getMonitor().isDragging(),
);
useEffect(() => {
const monitor = dragDropManager.getMonitor();
const unsub = monitor.subscribeToStateChange(() => {
const item = monitor.getItem() || {};
// don't show dragging state for the sorting item
if ('dragIndex' in item) {
return;
}
const isDragging = monitor.isDragging();
setDragging(isDragging);
});
return () => {
unsub();
};
}, [dragDropManager]);
const dropzoneValue = useReducer(reducer, {});
return (
<DropzoneContext.Provider value={dropzoneValue}>
<DraggingContext.Provider value={dragging}>
<StyledDiv>{children}</StyledDiv>
</DraggingContext.Provider>
</DropzoneContext.Provider>
);
};
const ExploreContainer: FC<{}> = ({ children }) => (
<ExploreDndContextProvider>
<StyledDiv>{children}</StyledDiv>
</ExploreDndContextProvider>
);
export default ExploreContainer;

View File

@@ -127,6 +127,8 @@ const ContourControl = ({ onChange, ...props }: ContourControlProps) => {
accept={[]}
ghostButtonText={ghostButtonText}
onClickGhostButton={handleClickGhostButton}
sortableType="ContourOption"
itemCount={contours.length}
{...props}
/>
<ContourPopoverTrigger

View File

@@ -16,15 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
fireEvent,
render,
screen,
within,
} from 'spec/helpers/testing-library';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import { DndColumnMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../../DndItemType';
const defaultProps = {
name: 'test-control',
@@ -67,7 +60,7 @@ const defaultProps = {
test('renders with default props', () => {
render(<DndColumnMetricSelect {...defaultProps} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -77,7 +70,7 @@ test('renders with default props', () => {
test('renders with default props and multi = true', () => {
render(<DndColumnMetricSelect {...defaultProps} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -88,149 +81,15 @@ test('renders with default props and multi = true', () => {
test('render selected columns and metrics correctly', () => {
const values = ['column_a', 'metric_a'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(screen.getByText('column_a')).toBeVisible();
expect(screen.getByText('metric_a')).toBeVisible();
});
test('can drop columns and metrics', () => {
const values = ['column_a', 'metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_b', uuid: '1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_b', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
const currentSelection = getByTestId('dnd-labels-container');
fireEvent.dragStart(columnOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
fireEvent.dragStart(metricOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection).toBeInTheDocument();
});
test('cannot drop duplicate items', () => {
const values = ['column_a', 'metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_a', uuid: '1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
const currentSelection = getByTestId('dnd-labels-container');
const initialCount = currentSelection.children.length;
fireEvent.dragStart(columnOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
fireEvent.dragStart(metricOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection.children).toHaveLength(initialCount);
});
test('can drop only selected metrics', () => {
const values = ['column_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_c', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
const selectedMetric = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const unselectedMetric = screen.getAllByTestId(
'DatasourcePanelDragOption',
)[1];
const currentSelection = getByTestId('dnd-labels-container');
const initialCount = currentSelection.children.length;
fireEvent.dragStart(unselectedMetric);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection.children).toHaveLength(initialCount);
fireEvent.dragStart(selectedMetric);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection).toBeInTheDocument();
});
test('can drag and reorder items', async () => {
const values = ['column_a', 'metric_a', 'column_b'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useRedux: true,
});
const container = screen.getByTestId('dnd-labels-container');
expect(container.childElementCount).toBe(4);
const firstItem = container.children[0] as HTMLElement;
const lastItem = container.children[2] as HTMLElement;
expect(within(firstItem).getByText('column_a')).toBeVisible();
expect(within(lastItem).getByText('Column B')).toBeVisible();
fireEvent.dragStart(firstItem);
fireEvent.dragEnter(lastItem);
fireEvent.dragOver(lastItem);
fireEvent.drop(lastItem);
expect(container).toBeInTheDocument();
});
// Note: Drag-and-drop tests removed - @dnd-kit uses pointer events instead of
// HTML5 drag events. These tests require @dnd-kit-compatible testing utilities.
test('shows warning for aggregated DeckGL charts', () => {
const values = ['column_a'];
@@ -243,7 +102,7 @@ test('shows warning for aggregated DeckGL charts', () => {
multi
formData={formData}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
const columnItem = screen.getByText('column_a');
@@ -261,7 +120,7 @@ test('handles single selection mode', () => {
multi={false}
onChange={onChange}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(screen.getByText('column_a')).toBeVisible();
@@ -275,7 +134,7 @@ test('handles custom ghost button text', () => {
render(
<DndColumnMetricSelect {...defaultProps} ghostButtonText={customText} />,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(screen.getByText(customText)).toBeInTheDocument();
@@ -292,10 +151,11 @@ test('can remove items by clicking close button', () => {
multi
onChange={onChange}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
const closeButtons = screen.getAllByRole('button', { name: /close/i });
// Use testId instead of role selector - @dnd-kit sortable wrapper adds extra button elements
const closeButtons = screen.getAllByTestId('remove-control-button');
expect(closeButtons).toHaveLength(2);
fireEvent.click(closeButtons[0]);
@@ -312,7 +172,7 @@ test('handles adhoc metric with error', () => {
const values = [errorMetric];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -324,7 +184,7 @@ test('handles adhoc column values', () => {
const values = ['column_a'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -336,7 +196,7 @@ test('handles mixed value types correctly', () => {
render(
<DndColumnMetricSelect {...defaultProps} value={mixedValues} multi />,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(screen.getByText('column_a')).toBeVisible();

View File

@@ -61,7 +61,7 @@ const defaultProps: DndColumnSelectProps = {
test('renders with default props', async () => {
render(<DndColumnSelect {...defaultProps} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -71,7 +71,7 @@ test('renders with default props', async () => {
test('renders with value', async () => {
render(<DndColumnSelect {...defaultProps} value="Column A" />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(await screen.findByText('Column A')).toBeInTheDocument();
@@ -87,7 +87,7 @@ test('renders adhoc column', async () => {
expressionType: 'SQL',
}}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(await screen.findByText('adhoc column')).toBeVisible();
expect(screen.getByLabelText('calculator')).toBeVisible();
@@ -110,7 +110,7 @@ test('warn selected custom metric when metric gets removed from dataset', async
value={columnValues}
/>,
{
useDnd: true,
useDndKit: true,
useRedux: true,
},
);
@@ -167,7 +167,7 @@ test('should allow selecting columns via click interface', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -200,7 +200,7 @@ test('should display selected column values correctly', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -233,7 +233,7 @@ test('should handle multiple column selections for groupby', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -269,7 +269,7 @@ test('should support adhoc column creation workflow', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -299,7 +299,7 @@ test('should verify onChange callback integration (core regression protection)',
};
const { rerender } = render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -334,7 +334,7 @@ test('should render column selection interface elements', async () => {
};
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -374,7 +374,7 @@ test('should complete full column selection workflow like original Cypress test'
});
const { rerender } = render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -450,7 +450,7 @@ test('should create adhoc column via Custom SQL tab workflow', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});

View File

@@ -185,6 +185,9 @@ function DndColumnSelect(props: DndColumnSelectProps) {
[ghostButtonText, multi],
);
// Generate sortable type that matches OptionWrapper's type
const sortableType = `${DndItemType.ColumnOption}_${name}_${label}`;
return (
<div>
<DndSelectLabel
@@ -195,6 +198,8 @@ function DndColumnSelect(props: DndColumnSelectProps) {
displayGhostButton={multi || optionSelector.values.length === 0}
ghostButtonText={labelGhostButtonText}
onClickGhostButton={openPopover}
sortableType={sortableType}
itemCount={optionSelector.values.length}
{...props}
/>
<ColumnSelectPopoverTrigger

View File

@@ -26,12 +26,7 @@ import {
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { ColumnMeta } from '@superset-ui/chart-controls';
import {
fireEvent,
render,
screen,
within,
} from 'spec/helpers/testing-library';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { Operators } from 'src/explore/constants';
@@ -42,8 +37,6 @@ import {
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
import { ExpressionTypes } from '../FilterControl/types';
import { Datasource } from '../../../types';
import { DndItemType } from '../../DndItemType';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
jest.mock('src/core/editors', () => ({
EditorHost: ({ value }: { value: string }) => (
@@ -101,7 +94,7 @@ beforeEach(() => {
});
test('renders with default props', async () => {
render(setup(), { useDnd: true, store });
render(setup(), { useDndKit: true, store });
expect(
await screen.findByText('Drop columns/metrics here or click'),
).toBeInTheDocument();
@@ -113,7 +106,7 @@ test('renders with value', async () => {
expressionType: ExpressionTypes.Sql,
});
render(setup({ value }), {
useDnd: true,
useDndKit: true,
store,
});
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
@@ -128,7 +121,7 @@ test('renders options with saved metric', async () => {
},
}),
{
useDnd: true,
useDndKit: true,
store,
},
);
@@ -150,7 +143,7 @@ test('renders options with column', async () => {
],
}),
{
useDnd: true,
useDndKit: true,
store,
},
);
@@ -172,7 +165,7 @@ test('renders options with adhoc metric', async () => {
},
}),
{
useDnd: true,
useDndKit: true,
store,
},
);
@@ -181,78 +174,8 @@ test('renders options with adhoc metric', async () => {
).toBeInTheDocument();
});
test('cannot drop a column that is not part of the simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'order_date' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ column_name: 'address_line1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{
metric_name: 'metric_a',
expression: 'AGG(metric_a)',
uuid: '1',
}}
type={DndItemType.Metric}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
columns: [{ column_name: 'order_date' }],
})}
</>,
{
useDnd: true,
store,
},
);
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableColumn = selections[0];
const unacceptableColumn = selections[1];
const metricType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(unacceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('order_date')).toBeInTheDocument();
fireEvent.keyDown(filterConfigPopup, {
key: 'Escape',
code: 'Escape',
keyCode: 27,
charCode: 27,
});
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(metricType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(
within(screen.getByTestId('filter-edit-popover')).getByTestId('react-ace'),
).toHaveTextContent('AGG(metric_a)');
});
// Note: Drag-and-drop tests removed - @dnd-kit uses pointer events instead of
// HTML5 drag events. These tests require @dnd-kit-compatible testing utilities.
test('calls onChange when close is clicked and canDelete is true', () => {
const value1 = new AdhocFilter({
@@ -268,7 +191,7 @@ test('calls onChange when close is clicked and canDelete is true', () => {
const canDelete = jest.fn();
canDelete.mockReturnValue(true);
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDnd: true,
useDndKit: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -290,7 +213,7 @@ test('onChange is not called when close is clicked and canDelete is false', () =
const canDelete = jest.fn();
canDelete.mockReturnValue(false);
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDnd: true,
useDndKit: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -312,7 +235,7 @@ test('onChange is not called when close is clicked and canDelete is string, warn
const canDelete = jest.fn();
canDelete.mockReturnValue('Test warning');
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDnd: true,
useDndKit: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -320,109 +243,3 @@ test('onChange is not called when close is clicked and canDelete is string, warn
expect(defaultProps.onChange).not.toHaveBeenCalled();
expect(await screen.findByText('Test warning')).toBeInTheDocument();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('when disallow_adhoc_metrics is set', () => {
test('can drop a column type from the simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_b' }}
type={DndItemType.Column}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_b' }],
})}
</>,
{
useDnd: true,
store,
},
);
const acceptableColumn = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_b')).toBeInTheDocument();
});
test('cannot drop any other types of selections apart from simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_c' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'avg__num', uuid: '2' }}
type={DndItemType.AdhocMetricOption}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_c' }],
})}
</>,
{
useDnd: true,
store,
},
);
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableColumn = selections[0];
const unacceptableMetric = selections[1];
const unacceptableType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(unacceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(unacceptableType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_c')).toBeInTheDocument();
});
});

View File

@@ -453,6 +453,8 @@ const DndFilterSelect = (props: DndFilterSelectProps) => {
accept={DND_ACCEPTED_TYPES}
ghostButtonText={t('Drop columns/metrics here or click')}
onClickGhostButton={handleClickGhostButton}
sortableType={DndItemType.FilterOption}
itemCount={values.length}
{...props}
/>
<AdhocFilterPopoverTrigger

View File

@@ -20,15 +20,13 @@ import {
fireEvent,
render,
screen,
within,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import { DndMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndMetricSelect';
import { AGGREGATES } from 'src/explore/constants';
import { EXPRESSION_TYPES } from '../MetricControl/AdhocMetric';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../../DndItemType';
const defaultProps = {
savedMetrics: [
@@ -69,14 +67,14 @@ const adhocMetricB = {
};
test('renders with default props', () => {
render(<DndMetricSelect {...defaultProps} />, { useDnd: true });
render(<DndMetricSelect {...defaultProps} />, { useDndKit: true });
expect(
screen.getByText('Drop a column/metric here or click'),
).toBeInTheDocument();
});
test('renders with default props and multi = true', () => {
render(<DndMetricSelect {...defaultProps} multi />, { useDnd: true });
render(<DndMetricSelect {...defaultProps} multi />, { useDndKit: true });
expect(
screen.getByText('Drop columns/metrics here or click'),
).toBeInTheDocument();
@@ -85,7 +83,7 @@ test('renders with default props and multi = true', () => {
test('render selected metrics correctly', () => {
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
useDnd: true,
useDndKit: true,
});
expect(screen.getByText('metric_a')).toBeVisible();
expect(screen.getByText('Metric B')).toBeVisible();
@@ -106,7 +104,7 @@ test('warn selected custom metric when metric gets removed from dataset', async
multi
/>,
{
useDnd: true,
useDndKit: true,
},
);
@@ -158,7 +156,7 @@ test('warn selected custom metric when metric gets removed from dataset for sing
multi={false}
/>,
{
useDnd: true,
useDndKit: true,
},
);
@@ -216,7 +214,7 @@ test('remove selected adhoc metric when column gets removed from dataset', async
multi
/>,
{
useDnd: true,
useDndKit: true,
},
);
@@ -258,7 +256,7 @@ test('update adhoc metric name when column label in dataset changes', () => {
multi
/>,
{
useDnd: true,
useDndKit: true,
},
);
@@ -300,153 +298,10 @@ test('update adhoc metric name when column label in dataset changes', () => {
expect(screen.getByText('SUM(new col B name)')).toBeVisible();
});
test('can drag metrics', async () => {
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
useDnd: true,
});
expect(screen.getByText('metric_a')).toBeVisible();
expect(screen.getByText('Metric B')).toBeVisible();
const container = screen.getByTestId('dnd-labels-container');
expect(container.childElementCount).toBe(4);
const firstMetric = container.children[0] as HTMLElement;
const lastMetric = container.children[2] as HTMLElement;
expect(within(firstMetric).getByText('metric_a')).toBeVisible();
expect(within(lastMetric).getByText('SUM(Column B)')).toBeVisible();
fireEvent.mouseOver(within(firstMetric).getByText('metric_a'));
expect(await screen.findByText('Metric name')).toBeInTheDocument();
fireEvent.dragStart(firstMetric);
fireEvent.dragEnter(lastMetric);
fireEvent.dragOver(lastMetric);
fireEvent.drop(lastMetric);
expect(within(firstMetric).getByText('SUM(Column B)')).toBeVisible();
expect(within(lastMetric).getByText('metric_a')).toBeVisible();
});
test('cannot drop a duplicated item', () => {
const metricValues = ['metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DndMetricSelect {...defaultProps} value={metricValues} multi />
</>,
{
useDnd: true,
},
);
const acceptableMetric = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
const currentMetricSelection = currentMetric.children.length;
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).toHaveTextContent('metric_a');
});
test('can drop a saved metric when disallow_adhoc_metrics', () => {
const metricValues = ['metric_b'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DndMetricSelect
{...defaultProps}
value={metricValues}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' }}
/>
</>,
{
useDnd: true,
},
);
const acceptableMetric = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
const currentMetricSelection = currentMetric.children.length;
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection + 1);
expect(currentMetric.children[1]).toHaveTextContent('metric_a');
});
test('cannot drop non-saved metrics when disallow_adhoc_metrics', () => {
const metricValues = ['metric_b'];
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_c', uuid: '2' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ column_name: 'column_1', uuid: '3' }}
type={DndItemType.Column}
/>
<DndMetricSelect
{...defaultProps}
value={metricValues}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' }}
/>
</>,
{
useDnd: true,
},
);
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableMetric = selections[0];
const unacceptableMetric = selections[1];
const unacceptableType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
const currentMetricSelection = currentMetric.children.length;
fireEvent.dragStart(unacceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).not.toHaveTextContent('metric_c');
fireEvent.dragStart(unacceptableType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).not.toHaveTextContent('column_1');
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection + 1);
expect(currentMetric).toHaveTextContent('metric_a');
});
// TODO: Restore drag-and-drop coverage using @dnd-kit-compatible utilities
// (e.g. @testing-library/user-event pointer event sequences). The previous
// tests relied on HTML5 fireEvent.dragStart/dragOver/drop, which @dnd-kit
// does not respond to, so they were removed rather than left as no-ops.
test('title changes on custom SQL text change', async () => {
let metricValues = [adhocMetricA, 'metric_b'];
@@ -462,7 +317,7 @@ test('title changes on custom SQL text change', async () => {
multi
/>,
{
useDnd: true,
useDndKit: true,
},
);

View File

@@ -377,6 +377,9 @@ const DndMetricSelect = (props: any) => {
multi ? 2 : 1,
);
// Generate sortable type that matches MetricDefinitionValue's type
const sortableType = `${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`;
return (
<div className="metrics-select">
<DndSelectLabel
@@ -387,6 +390,8 @@ const DndMetricSelect = (props: any) => {
ghostButtonText={ghostButtonText}
displayGhostButton={multi || value.length === 0}
onClickGhostButton={handleClickGhostButton}
sortableType={sortableType}
itemCount={value.length}
{...props}
/>
<AdhocMetricPopoverTrigger

View File

@@ -52,7 +52,7 @@ const MockChildren = () => {
};
test('renders with default props', () => {
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
expect(screen.getByText('Drop columns here or click')).toBeInTheDocument();
});
@@ -60,7 +60,7 @@ test('renders ghost button when empty', () => {
const ghostButtonText = 'Ghost button text';
render(
<DndSelectLabel {...defaultProps} ghostButtonText={ghostButtonText} />,
{ useDnd: true },
{ useDndKit: true },
);
expect(screen.getByText(ghostButtonText)).toBeInTheDocument();
});
@@ -69,13 +69,13 @@ test('renders values', () => {
const values = 'Values';
const valuesRenderer = () => <span>{values}</span>;
render(<DndSelectLabel {...defaultProps} valuesRenderer={valuesRenderer} />, {
useDnd: true,
useDndKit: true,
});
expect(screen.getByText(values)).toBeInTheDocument();
});
test('Handles ghost button click', () => {
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
userEvent.click(screen.getByText('Drop columns here or click'));
expect(defaultProps.onClickGhostButton).toHaveBeenCalled();
});
@@ -86,7 +86,6 @@ test('updates dropValidator on changes', () => {
<DndSelectLabel {...defaultProps} />
<MockChildren />
</ExploreContainer>,
{ useDnd: true },
);
expect(getByTestId(`mock-result-${defaultProps.name}`)).toHaveTextContent(
'false',

View File

@@ -17,7 +17,11 @@
* under the License.
*/
import { ReactNode, useCallback, useContext, useEffect, useMemo } from 'react';
import { useDrop } from 'react-dnd';
import { useDroppable } from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { t } from '@apache-superset/core/translation';
import ControlHeader from 'src/explore/components/ControlHeader';
import {
@@ -45,6 +49,9 @@ export type DndSelectLabelProps = {
displayGhostButton?: boolean;
onClickGhostButton: () => void;
isLoading?: boolean;
// For sortable items - the type string and count to generate sortable IDs
sortableType?: string;
itemCount?: number;
};
export default function DndSelectLabel({
@@ -52,34 +59,53 @@ export default function DndSelectLabel({
accept,
valuesRenderer,
isLoading,
sortableType,
itemCount = 0,
...props
}: DndSelectLabelProps) {
const canDropProp = props.canDrop;
const canDropValueProp = props.canDropValue;
const acceptTypes = useMemo(
() => (Array.isArray(accept) ? accept : [accept]),
[accept],
);
const dropValidator = useCallback(
(item: DatasourcePanelDndItem) =>
canDropProp(item) && (canDropValueProp?.(item.value) ?? true),
[canDropProp, canDropValueProp],
);
const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({
accept: isLoading ? [] : accept,
drop: (item: DatasourcePanelDndItem) => {
props.onDrop(item);
props.onDropValue?.(item.value);
const { setNodeRef, isOver, active } = useDroppable({
id: `dropzone-${props.name}`,
disabled: isLoading,
data: {
accept: acceptTypes,
onDrop: props.onDrop,
onDropValue: props.onDropValue,
canDrop: dropValidator,
},
canDrop: dropValidator,
collect: monitor => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
type: monitor.getItemType(),
}),
});
// Check if the active dragged item can be dropped here
const canDrop = useMemo(() => {
if (!active?.data.current) return false;
const activeData = active.data.current as {
type: string;
value: unknown;
dragIndex?: number;
};
// Skip sortable reorder drags (they carry a dragIndex) - those are handled
// as list reorders in ExploreDndContext, not as external drops.
if (typeof activeData.dragIndex === 'number') return false;
if (!acceptTypes.includes(activeData.type as DndItemType)) return false;
return dropValidator({
type: activeData.type as DndItemType,
value: activeData.value as DndItemValue,
});
}, [active, acceptTypes, dropValidator]);
const dispatch = useContext(DropzoneContext)[1];
useEffect(() => {
@@ -93,6 +119,15 @@ export default function DndSelectLabel({
const values = useMemo(() => valuesRenderer(), [valuesRenderer]);
// Generate sortable item IDs for SortableContext
const sortableItemIds = useMemo(() => {
if (!sortableType || itemCount === 0) return [];
return Array.from(
{ length: itemCount },
(_, i) => `sortable-${sortableType}-${i}`,
);
}, [sortableType, itemCount]);
function renderGhostButton() {
return (
<AddControlLabel
@@ -105,8 +140,31 @@ export default function DndSelectLabel({
);
}
// Handle drop events from dnd-kit
useEffect(() => {
if (isOver && active?.data.current && canDrop) {
// The actual drop is handled in ExploreDndContext's onDragEnd
// This effect is for any side effects needed during hover
}
}, [isOver, active, canDrop]);
// Wrap values in SortableContext if sortable
const renderSortableValues = () => {
if (sortableItemIds.length > 0) {
return (
<SortableContext
items={sortableItemIds}
strategy={verticalListSortingStrategy}
>
{values}
</SortableContext>
);
}
return values;
};
return (
<div ref={datasourcePanelDrop}>
<div ref={setNodeRef}>
<HeaderContainer>
<ControlHeader {...props} />
</HeaderContainer>
@@ -117,7 +175,7 @@ export default function DndSelectLabel({
isDragging={isDragging}
isLoading={isLoading}
>
{values}
{renderSortableValues()}
{displayGhostButton && renderGhostButton()}
</DndLabelsContainer>
</div>

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import { render, screen } from 'spec/helpers/testing-library';
import { DndItemType } from 'src/explore/components/DndItemType';
import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper';
@@ -29,35 +29,66 @@ test('renders with default props', async () => {
onShiftOptions={jest.fn()}
label="Option"
/>,
{ useDnd: true },
{ useDndKit: true },
);
expect(container).toBeInTheDocument();
expect(await screen.findByRole('img', { name: 'close' })).toBeInTheDocument();
});
test('triggers onShiftOptions on drop', async () => {
const onShiftOptions = jest.fn();
test('renders label correctly', async () => {
render(
<OptionWrapper
index={1}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Test Label"
/>,
{ useDndKit: true },
);
expect(await screen.findByText('Test Label')).toBeInTheDocument();
});
test('renders multiple options', async () => {
render(
<>
<OptionWrapper
index={0}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Option 1"
/>
<OptionWrapper
index={1}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={onShiftOptions}
label="Option 1"
/>
<OptionWrapper
index={2}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={onShiftOptions}
onShiftOptions={jest.fn()}
label="Option 2"
/>
</>,
{ useDnd: true },
{ useDndKit: true },
);
fireEvent.dragStart(await screen.findByText('Option 1'));
fireEvent.drop(await screen.findByText('Option 2'));
expect(onShiftOptions).toHaveBeenCalled();
expect(await screen.findByText('Option 1')).toBeInTheDocument();
expect(await screen.findByText('Option 2')).toBeInTheDocument();
});
test('calls clickClose when close button is clicked', async () => {
const clickClose = jest.fn();
render(
<OptionWrapper
index={1}
clickClose={clickClose}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Option"
/>,
{ useDndKit: true },
);
const closeButton = await screen.findByRole('img', { name: 'close' });
closeButton.click();
expect(clickClose).toHaveBeenCalledWith(1);
});

View File

@@ -16,13 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef } from 'react';
import {
useDrag,
useDrop,
DropTargetMonitor,
DragSourceMonitor,
} from 'react-dnd';
import { useRef, useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { DragContainer } from 'src/explore/components/controls/OptionControls';
import {
OptionProps,
@@ -64,62 +60,32 @@ export default function OptionWrapper(
multiValueWarningMessage,
...rest
} = props;
const ref = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const [{ isDragging }, drag] = useDrag({
item: {
// Create a unique sortable ID for this item
const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortableId,
data: {
type,
dragIndex: index,
},
collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging(),
}),
onShiftOptions,
} as OptionItemInterface & { onShiftOptions: typeof onShiftOptions },
});
const [, drop] = useDrop({
accept: type,
hover: (item: OptionItemInterface, monitor: DropTargetMonitor) => {
if (!ref.current) {
return;
}
const { dragIndex } = item;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset
? clientOffset.y - hoverBoundingRect.top
: 0;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Time to actually perform the action
onShiftOptions(dragIndex, hoverIndex);
// eslint-disable-next-line no-param-reassign
item.dragIndex = hoverIndex;
},
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const shouldShowTooltip =
(!isDragging && tooltipTitle && label && tooltipTitle !== label) ||
@@ -179,10 +145,14 @@ export default function OptionWrapper(
return null;
};
drag(drop(ref));
return (
<DragContainer ref={ref} {...rest}>
<DragContainer
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
{...rest}
>
<Option
index={index}
clickClose={clickClose}

View File

@@ -73,7 +73,7 @@ test('should render the control label', async () => {
test('should render the remove button', async () => {
render(setup(mockedProps), { useDnd: true, useRedux: true });
const removeBtn = await screen.findByRole('button');
const removeBtn = await screen.findByTestId('remove-control-button');
expect(removeBtn).toBeInTheDocument();
});

View File

@@ -16,12 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
screen,
fireEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import {
OptionControlLabel,
DragContainer,
@@ -48,7 +43,7 @@ const defaultProps = {
const setup = (overrides?: Record<string, any>) =>
render(<OptionControlLabel {...defaultProps} {...overrides} />, {
useDnd: true,
useDndKit: true,
});
test('should render', async () => {
@@ -88,7 +83,7 @@ test('should display a certification icon if saved metric is certified', async (
);
});
test('triggers onMoveLabel on drop', async () => {
test('renders multiple labels', async () => {
render(
<>
<OptionControlLabel
@@ -101,15 +96,11 @@ test('triggers onMoveLabel on drop', async () => {
index={2}
label={<span>Label 2</span>}
/>
,
</>,
{ useDnd: true },
{ useDndKit: true },
);
await waitFor(() => {
fireEvent.dragStart(screen.getByText('Label 1'));
fireEvent.drop(screen.getByText('Label 2'));
expect(defaultProps.onMoveLabel).toHaveBeenCalled();
});
expect(await screen.findByText('Label 1')).toBeInTheDocument();
expect(await screen.findByText('Label 2')).toBeInTheDocument();
});
test('renders DragContainer', () => {

View File

@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef, ReactNode } from 'react';
import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
import { useRef, ReactNode, useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme, css, keyframes } from '@apache-superset/core/theme';
import { InfoTooltip, Icons, Tooltip } from '@superset-ui/core/components';
@@ -233,9 +233,12 @@ export const AddIconButton = styled.button`
}
`;
interface DragItem {
dragIndex: number;
export interface SortableItemData {
type: string;
dragIndex: number;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
value?: savedMetricType | AdhocMetric;
}
export const OptionControlLabel = ({
@@ -272,73 +275,37 @@ export const OptionControlLabel = ({
multi?: boolean;
}) => {
const theme = useTheme();
const ref = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const hasMetricName = savedMetric?.metric_name;
const [, drop] = useDrop({
accept: type,
drop() {
if (!multi) {
return;
}
onDropLabel?.();
},
hover(item: DragItem, monitor: DropTargetMonitor) {
if (!multi) {
return;
}
if (!ref.current) {
return;
}
const { dragIndex } = item;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset?.y
? clientOffset?.y - hoverBoundingRect.top
: 0;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Time to actually perform the action
onMoveLabel?.(dragIndex, hoverIndex);
// Note: we're mutating the monitor item here!
// Generally it's better to avoid mutations,
// but it's good here for the sake of performance
// to avoid expensive index searches.
// eslint-disable-next-line no-param-reassign
item.dragIndex = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
item: {
// Create a unique sortable ID for this item
const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortableId,
disabled: !multi,
data: {
type,
dragIndex: index,
onMoveLabel,
onDropLabel,
value: savedMetric?.metric_name ? savedMetric : adhocMetric,
},
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
} as SortableItemData,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const getLabelContent = () => {
const shouldShowTooltip =
(!isDragging &&
@@ -423,6 +390,14 @@ export const OptionControlLabel = ({
</OptionControlContainer>
);
drag(drop(ref));
return <DragContainer ref={ref}>{getOptionControlContent()}</DragContainer>;
return (
<DragContainer
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
>
{getOptionControlContent()}
</DragContainer>
);
};

View File

@@ -172,7 +172,9 @@ interface ExploreSlice {
interface ExploreState {
charts?: Record<number, ChartState>;
explore?: ExploreSlice;
explore?: ExploreSlice & {
chartStates?: Record<number, JsonObject>;
};
common?: {
conf?: {
CSV_STREAMING_ROW_THRESHOLD?: number;
@@ -221,6 +223,15 @@ export const useExploreAdditionalActionsMenu = (
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
);
const exploreChartState = useSelector<
ExploreState,
JsonObject | undefined
>(state => {
const chartKey = state.explore ? getChartKey(state.explore) : undefined;
return chartKey != null
? state.explore?.chartStates?.[chartKey]
: undefined;
});
// Streaming export state and handlers
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
@@ -274,6 +285,9 @@ export const useExploreAdditionalActionsMenu = (
'EXPORT_CURRENT_VIEW' as Behavior,
);
const permalinkChartState = (exploreChartState as { state?: JsonObject })
?.state;
const shareByEmail = useCallback(async () => {
try {
const subject = t('Superset Chart');
@@ -282,6 +296,8 @@ export const useExploreAdditionalActionsMenu = (
}
const result = await getChartPermalink(
latestQueryFormData as Pick<QueryFormData, 'datasource'>,
undefined,
permalinkChartState,
);
if (!result?.url) {
throw new Error('Failed to generate permalink');
@@ -293,7 +309,7 @@ export const useExploreAdditionalActionsMenu = (
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
}, [addDangerToast, latestQueryFormData]);
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
const exportCSV = useCallback(() => {
if (!canDownloadCSV) return null;
@@ -411,6 +427,8 @@ export const useExploreAdditionalActionsMenu = (
await copyTextToClipboard(async () => {
const result = await getChartPermalink(
latestQueryFormData as Pick<QueryFormData, 'datasource'>,
undefined,
permalinkChartState,
);
if (!result?.url) {
throw new Error('Failed to generate permalink');
@@ -421,7 +439,7 @@ export const useExploreAdditionalActionsMenu = (
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
}, [addDangerToast, addSuccessToast, latestQueryFormData, permalinkChartState]);
// Minimal client-side CSV builder used for "Current View" when pagination is disabled
const downloadClientCSV = (

View File

@@ -17,7 +17,12 @@
* under the License.
*/
/* eslint camelcase: 0 */
import { ensureIsArray, QueryFormData, JsonValue } from '@superset-ui/core';
import {
ensureIsArray,
QueryFormData,
JsonValue,
JsonObject,
} from '@superset-ui/core';
import {
ControlState,
ControlStateMapping,
@@ -66,6 +71,7 @@ export interface ExploreState {
owners?: string[] | null;
};
saveAction?: SaveActionType | null;
chartStates?: Record<number, JsonObject>;
}
// Action type definitions
@@ -165,6 +171,13 @@ interface SetForceQueryAction {
force: boolean;
}
interface UpdateExploreChartStateAction {
type: typeof actions.UPDATE_EXPLORE_CHART_STATE;
chartId: number;
chartState: Record<string, unknown>;
lastModified: number;
}
type ExploreAction =
| DynamicPluginControlsReadyAction
| ToggleFaveStarAction
@@ -183,6 +196,7 @@ type ExploreAction =
| SetStashFormDataAction
| SliceUpdatedAction
| SetForceQueryAction
| UpdateExploreChartStateAction
| HydrateExplore;
// Extended control state for dynamic form controls - uses Record for flexibility
@@ -621,10 +635,25 @@ export default function exploreReducer(
force: typedAction.force,
};
},
[actions.UPDATE_EXPLORE_CHART_STATE]() {
const typedAction = action as UpdateExploreChartStateAction;
return {
...state,
chartStates: {
...state.chartStates,
[typedAction.chartId]: {
chartId: typedAction.chartId,
state: typedAction.chartState,
lastModified: typedAction.lastModified,
},
},
};
},
[HYDRATE_EXPLORE]() {
const typedAction = action as HydrateExplore;
const exploreData = typedAction.data.explore;
return {
...typedAction.data.explore,
...exploreData,
} as ExploreState;
},
};

View File

@@ -98,7 +98,10 @@ export interface ExplorePageInitialData {
}
export interface ExploreResponsePayload {
result: ExplorePageInitialData & { message: string };
result: ExplorePageInitialData & {
message: string;
chartState?: JsonObject;
};
}
export interface ExplorePageState {

View File

@@ -150,11 +150,27 @@ export default function ExplorePage() {
)
: result.form_data;
let chartStates: Record<number, JsonObject> | undefined;
if (result.chartState) {
const sliceId =
getUrlParam(URL_PARAMS.sliceId) ||
(formData as JsonObject).slice_id ||
0;
chartStates = {
[sliceId]: {
chartId: sliceId,
state: result.chartState,
lastModified: Date.now(),
},
};
}
dispatch(
hydrateExplore({
...result,
form_data: formData,
saveAction,
chartStates,
}),
);
})

View File

@@ -195,11 +195,16 @@ async function resolvePermalinkUrl(
export async function getChartPermalink(
formData: Pick<QueryFormData, 'datasource'>,
excludedUrlParams?: string[],
chartState?: JsonObject,
): Promise<PermalinkResult> {
const result = await getPermalink('/api/v1/explore/permalink', {
const payload: JsonObject = {
formData,
urlParams: getChartUrlParams(excludedUrlParams),
});
};
if (chartState && Object.keys(chartState).length > 0) {
payload.chartState = chartState;
}
const result = await getPermalink('/api/v1/explore/permalink', payload);
return resolvePermalinkUrl(result);
}

View File

@@ -26,7 +26,7 @@
"@types/node": "^25.3.3",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.55.0",
"@typescript-eslint/parser": "^8.57.0",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
@@ -1883,16 +1883,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz",
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1",
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/typescript-estree": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0",
"debug": "^4.4.3"
},
"engines": {
@@ -1907,6 +1907,175 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz",
"integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.57.0",
"@typescript-eslint/types": "^8.57.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz",
"integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz",
"integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz",
"integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz",
"integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.57.0",
"@typescript-eslint/tsconfig-utils": "8.57.0",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz",
"integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
@@ -6222,6 +6391,31 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -7962,16 +8156,109 @@
}
},
"@typescript-eslint/parser": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz",
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1",
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/typescript-estree": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0",
"debug": "^4.4.3"
},
"dependencies": {
"@typescript-eslint/project-service": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz",
"integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==",
"dev": true,
"requires": {
"@typescript-eslint/tsconfig-utils": "^8.57.0",
"@typescript-eslint/types": "^8.57.0",
"debug": "^4.4.3"
}
},
"@typescript-eslint/scope-manager": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz",
"integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0"
}
},
"@typescript-eslint/tsconfig-utils": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz",
"integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==",
"dev": true,
"requires": {}
},
"@typescript-eslint/types": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz",
"integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz",
"integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==",
"dev": true,
"requires": {
"@typescript-eslint/project-service": "8.57.0",
"@typescript-eslint/tsconfig-utils": "8.57.0",
"@typescript-eslint/types": "8.57.0",
"@typescript-eslint/visitor-keys": "8.57.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
}
},
"@typescript-eslint/visitor-keys": {
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz",
"integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.57.0",
"eslint-visitor-keys": "^5.0.0"
}
},
"balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true
},
"brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"dev": true,
"requires": {
"balanced-match": "^4.0.2"
}
},
"eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true
},
"minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"requires": {
"brace-expansion": "^5.0.2"
}
}
}
},
"@typescript-eslint/project-service": {
@@ -11053,6 +11340,21 @@
"@typescript-eslint/parser": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/utils": "8.56.1"
},
"dependencies": {
"@typescript-eslint/parser": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/visitor-keys": "8.56.1",
"debug": "^4.4.3"
}
}
}
},
"uglify-js": {

View File

@@ -34,7 +34,7 @@
"@types/node": "^25.3.3",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.55.0",
"@typescript-eslint/parser": "^8.57.0",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",

View File

@@ -62,6 +62,7 @@ class GetExploreCommand(BaseCommand, ABC):
# pylint: disable=too-many-locals,too-many-branches,too-many-statements
def run(self) -> Optional[dict[str, Any]]: # noqa: C901
initial_form_data = {}
permalink_chart_state = None
if self._permalink_key is not None:
command = GetExplorePermalinkCommand(self._permalink_key)
permalink_value = command.run()
@@ -72,6 +73,7 @@ class GetExploreCommand(BaseCommand, ABC):
url_params = state.get("urlParams")
if url_params:
initial_form_data["url_params"] = dict(url_params)
permalink_chart_state = state.get("chartState")
elif self._form_data_key:
parameters = FormDataCommandParameters(key=self._form_data_key)
value = GetFormDataCommand(parameters).run()
@@ -168,13 +170,16 @@ class GetExploreCommand(BaseCommand, ABC):
if slc.changed_by:
metadata["changed_by"] = slc.changed_by.get_full_name()
return {
result: dict[str, Any] = {
"dataset": sanitize_datasource_data(datasource_data),
"form_data": form_data,
"slice": slc.data if slc else None,
"message": message,
"metadata": metadata,
}
if permalink_chart_state:
result["chartState"] = permalink_chart_state
return result
def validate(self) -> None:
pass

View File

@@ -41,6 +41,16 @@ class ExplorePermalinkStateSchema(Schema):
allow_none=True,
metadata={"description": "URL Parameters"},
)
chartState = fields.Dict( # noqa: N815
required=False,
allow_none=True,
metadata={
"description": (
"Chart-level state for stateful tables "
"(column filters, sorting, column order)"
)
},
)
class ExplorePermalinkSchema(Schema):

View File

@@ -20,6 +20,7 @@ from typing import Any, Optional, TypedDict
class ExplorePermalinkState(TypedDict, total=False):
formData: dict[str, Any]
urlParams: Optional[list[tuple[str, str]]]
chartState: Optional[dict[str, Any]]
class ExplorePermalinkValue(TypedDict):

View File

@@ -451,7 +451,13 @@ class GenerateDashboardRequest(BaseModel):
chart_ids: List[int] = Field(
..., description="List of chart IDs to include in the dashboard", min_length=1
)
dashboard_title: str = Field(..., description="Title for the new dashboard")
dashboard_title: str | None = Field(
None,
description=(
"Title for the new dashboard. When omitted a descriptive title "
"is generated from the included chart names."
),
)
description: str | None = Field(None, description="Description for the dashboard")
published: bool = Field(
default=True, description="Whether to publish the dashboard"

View File

@@ -22,6 +22,7 @@ This tool adds a chart to an existing dashboard with automatic layout positionin
"""
import logging
import re
from typing import Any, Dict
from fastmcp import Context
@@ -44,6 +45,22 @@ from superset.utils import json
logger = logging.getLogger(__name__)
# Compiled regex for stripping common emoji Unicode ranges from tab text.
# Uses specific Unicode blocks to avoid overly permissive ranges.
_EMOJI_RE = re.compile(
"["
"\U0001f300-\U0001f5ff" # Misc Symbols and Pictographs
"\U0001f600-\U0001f64f" # Emoticons
"\U0001f680-\U0001f6ff" # Transport and Map Symbols
"\U0001f900-\U0001f9ff" # Supplemental Symbols and Pictographs
"\U0001fa70-\U0001faff" # Symbols and Pictographs Extended-A
"\u2600-\u26ff" # Misc Symbols
"\u2700-\u27bf" # Dingbats
"\ufe00-\ufe0f" # Variation Selectors
"\u200d" # Zero-width joiner
"]+"
)
def _find_next_row_position(layout: Dict[str, Any]) -> str:
"""
@@ -63,35 +80,99 @@ def _find_next_row_position(layout: Dict[str, Any]) -> str:
return row_key
def _find_tab_insert_target(layout: Dict[str, Any]) -> str | None:
def _normalize_tab_text(text: str | None) -> str:
"""Strip emoji and extra whitespace from tab text for flexible matching."""
if not text:
return ""
cleaned = _EMOJI_RE.sub("", text)
return cleaned.strip().lower()
def _match_tab_in_children(
layout: Dict[str, Any],
tabs_children: list[str],
target_tab: str,
) -> str | None:
"""Search tabs_children for a tab matching target_tab by ID or name.
Matching is flexible: exact ID match, exact text match, or
case-insensitive text match after stripping emoji characters.
"""
Detect if the dashboard uses tabs and return the first tab's ID.
target_normalized = _normalize_tab_text(target_tab)
for tab_id in tabs_children:
tab = layout.get(tab_id)
if not tab or tab.get("type") != "TAB":
continue
tab_text = (tab.get("meta") or {}).get("text", "")
# Exact match on ID or text
if target_tab in (tab_id, tab_text):
return tab_id
# Flexible match: case-insensitive, emoji-stripped
if target_normalized and _normalize_tab_text(tab_text) == target_normalized:
return tab_id
return None
If ``GRID_ID`` has children that are ``TABS`` components, this walks
into the first ``TAB`` child so that new rows are placed inside the
active tab rather than directly under GRID_ID.
Returns:
The ID of the first TAB component, or ``None`` if the dashboard
does not use top-level tabs.
def _collect_tabs_groups(layout: Dict[str, Any]) -> list[list[str]]:
"""Collect all TABS groups from ROOT_ID and GRID_ID children.
Superset dashboards can place TABS under either ROOT_ID or GRID_ID
depending on how the layout was constructed.
"""
grid = layout.get("GRID_ID")
if not grid:
return None
for child_id in grid.get("children", []):
child = layout.get(child_id)
if child and child.get("type") == "TABS":
# Found a TABS component; use its first TAB child
groups: list[list[str]] = []
for parent_key in ("ROOT_ID", "GRID_ID"):
parent = layout.get(parent_key)
if not parent:
continue
for child_id in parent.get("children", []):
child = layout.get(child_id)
if not child or child.get("type") != "TABS":
continue
tabs_children = child.get("children", [])
if tabs_children:
first_tab_id = tabs_children[0]
first_tab = layout.get(first_tab_id)
if first_tab and first_tab.get("type") == "TAB":
return first_tab_id
groups.append(tabs_children)
return groups
def _first_tab_from_groups(
layout: Dict[str, Any], groups: list[list[str]]
) -> str | None:
"""Return the first valid TAB ID from the collected groups."""
for tabs_children in groups:
first_tab_id = tabs_children[0]
first_tab = layout.get(first_tab_id)
if first_tab and first_tab.get("type") == "TAB":
return first_tab_id
return None
def _find_tab_insert_target(
layout: Dict[str, Any], target_tab: str | None = None
) -> str | None:
"""
Detect if the dashboard uses tabs and return the appropriate tab's ID.
If *target_tab* is provided the function first tries to match it against
tab ``meta.text`` (display name) or the raw component ID. When no match
is found (or *target_tab* is ``None``) the first ``TAB`` child is used as
a fallback so that new rows are still placed inside the tab structure
rather than directly under ``GRID_ID``.
Returns:
The ID of the matched (or first) TAB component, or ``None`` if the
dashboard does not use top-level tabs.
"""
groups = _collect_tabs_groups(layout)
if target_tab:
for tabs_children in groups:
matched = _match_tab_in_children(layout, tabs_children, target_tab)
if matched:
return matched
return _first_tab_from_groups(layout, groups)
def _add_chart_to_layout(
layout: Dict[str, Any],
chart: Any,
@@ -284,8 +365,10 @@ def add_chart_to_existing_dashboard(
# Generate a unique ROW ID for the new row
row_key = _find_next_row_position(current_layout)
# Detect tabbed dashboards: if GRID has TABS, target the first tab
tab_target = _find_tab_insert_target(current_layout)
# Detect tabbed dashboards and resolve target_tab by name or ID
tab_target = _find_tab_insert_target(
current_layout, target_tab=request.target_tab
)
parent_id = tab_target if tab_target else "GRID_ID"
# Add chart, column, and row to layout

View File

@@ -138,6 +138,45 @@ def _create_dashboard_layout(chart_objects: List[Any]) -> Dict[str, Any]:
return layout
_DEFAULT_DASHBOARD_TITLE = "Dashboard"
_MAX_TITLE_LENGTH = 150
def _generate_title_from_charts(chart_objects: List[Any]) -> str:
"""
Build a descriptive dashboard title from the included chart names.
Joins up to three chart ``slice_name`` values with " & " (two charts)
or ", " (three charts). When there are more than three charts the
remaining count is appended as "+ N more". The result is capped at
``_MAX_TITLE_LENGTH`` characters.
Returns ``"Dashboard"`` when *chart_objects* is empty or no chart has
a usable name.
"""
names = [
c.slice_name
for c in sorted(chart_objects, key=lambda c: getattr(c, "id", 0))
if getattr(c, "slice_name", None)
]
if not names:
return _DEFAULT_DASHBOARD_TITLE
if len(names) == 1:
title = names[0]
elif len(names) == 2:
title = f"{names[0]} & {names[1]}"
elif len(names) == 3:
title = f"{names[0]}, {names[1]}, {names[2]}"
else:
title = f"{names[0]}, {names[1]}, {names[2]} + {len(names) - 3} more"
if len(title) > _MAX_TITLE_LENGTH:
title = title[: _MAX_TITLE_LENGTH - 1] + "\u2026"
return title
@tool(tags=["mutate"])
@parse_request(GenerateDashboardRequest)
def generate_dashboard(
@@ -160,7 +199,10 @@ def generate_dashboard(
with event_logger.log_context(action="mcp.generate_dashboard.chart_validation"):
chart_objects = (
db.session.query(Slice).filter(Slice.id.in_(request.chart_ids)).all()
db.session.query(Slice)
.filter(Slice.id.in_(request.chart_ids))
.order_by(Slice.id)
.all()
)
found_chart_ids = [chart.id for chart in chart_objects]
@@ -177,10 +219,17 @@ def generate_dashboard(
with event_logger.log_context(action="mcp.generate_dashboard.layout"):
layout = _create_dashboard_layout(chart_objects)
# Resolve dashboard title: use provided title or derive from chart names
dashboard_title = (
request.dashboard_title
if request.dashboard_title is not None
else _generate_title_from_charts(chart_objects)
)
# Prepare dashboard data and create dashboard
with event_logger.log_context(action="mcp.generate_dashboard.db_write"):
dashboard_data = {
"dashboard_title": request.dashboard_title,
"dashboard_title": dashboard_title,
"slug": None, # Let Superset auto-generate slug
"css": "",
"json_metadata": json.dumps(

View File

@@ -33,6 +33,9 @@ from superset.mcp_service.dashboard.tool.add_chart_to_existing_dashboard import
_find_next_row_position,
_find_tab_insert_target,
)
from superset.mcp_service.dashboard.tool.generate_dashboard import (
_generate_title_from_charts,
)
from superset.utils import json
logging.basicConfig(level=logging.DEBUG)
@@ -98,6 +101,7 @@ class TestGenerateDashboard:
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.order_by.return_value = mock_filter
mock_filter.all.return_value = [
_mock_chart(id=1, slice_name="Sales Chart"),
_mock_chart(id=2, slice_name="Revenue Chart"),
@@ -136,6 +140,7 @@ class TestGenerateDashboard:
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.order_by.return_value = mock_filter
mock_filter.all.return_value = [_mock_chart(id=1)]
mock_db_session.query.return_value = mock_query
@@ -159,6 +164,7 @@ class TestGenerateDashboard:
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.order_by.return_value = mock_filter
mock_filter.all.return_value = [_mock_chart(id=5, slice_name="Single Chart")]
mock_db_session.query.return_value = mock_query
@@ -189,6 +195,7 @@ class TestGenerateDashboard:
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.order_by.return_value = mock_filter
mock_filter.all.return_value = [
_mock_chart(id=i, slice_name=f"Chart {i}") for i in chart_ids
]
@@ -258,6 +265,7 @@ class TestGenerateDashboard:
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.order_by.return_value = mock_filter
mock_filter.all.return_value = [_mock_chart(id=1)]
mock_db_session.query.return_value = mock_query
mock_create_command.return_value.run.side_effect = Exception("Creation failed")
@@ -281,6 +289,7 @@ class TestGenerateDashboard:
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.order_by.return_value = mock_filter
mock_filter.all.return_value = [_mock_chart(id=3)]
mock_db_session.query.return_value = mock_query
@@ -307,6 +316,67 @@ class TestGenerateDashboard:
"description" not in call_args or call_args.get("description") is None
)
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_generate_dashboard_auto_title_from_charts(
self, mock_db_session, mock_create_command, mcp_server
):
"""Test that omitting dashboard_title generates a title from chart names."""
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.order_by.return_value = mock_filter
mock_filter.all.return_value = [
_mock_chart(id=1, slice_name="Sales Revenue"),
_mock_chart(id=2, slice_name="Customer Count"),
]
mock_db_session.query.return_value = mock_query
mock_dashboard = _mock_dashboard(id=50, title="Sales Revenue & Customer Count")
mock_create_command.return_value.run.return_value = mock_dashboard
# No dashboard_title provided
request = {"chart_ids": [1, 2]}
async with Client(mcp_server) as client:
result = await client.call_tool("generate_dashboard", {"request": request})
assert result.structured_content["error"] is None
call_args = mock_create_command.call_args[0][0]
assert call_args["dashboard_title"] == "Sales Revenue & Customer Count"
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_generate_dashboard_empty_string_title_preserved(
self, mock_db_session, mock_create_command, mcp_server
):
"""Test that an explicit empty-string title is NOT replaced by auto-gen."""
mock_query = Mock()
mock_filter = Mock()
mock_query.filter.return_value = mock_filter
mock_filter.order_by.return_value = mock_filter
mock_filter.all.return_value = [
_mock_chart(id=1, slice_name="Sales Revenue"),
]
mock_db_session.query.return_value = mock_query
mock_dashboard = _mock_dashboard(id=60, title="")
mock_create_command.return_value.run.return_value = mock_dashboard
# Explicit empty string title
request = {"chart_ids": [1], "dashboard_title": ""}
async with Client(mcp_server) as client:
result = await client.call_tool("generate_dashboard", {"request": request})
assert result.structured_content["error"] is None
call_args = mock_create_command.call_args[0][0]
assert call_args["dashboard_title"] == ""
class TestAddChartToExistingDashboard:
"""Tests for add_chart_to_existing_dashboard MCP tool."""
@@ -606,6 +676,107 @@ class TestAddChartToExistingDashboard:
assert "TABS-abc123" in chart_parents
assert "TAB-tab1" in chart_parents
@patch("superset.commands.dashboard.update.UpdateDashboardCommand")
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@patch("superset.db.session")
@pytest.mark.asyncio
async def test_add_chart_to_specific_tab_by_name(
self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server
):
"""Test adding chart to a specific tab using target_tab name."""
mock_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard")
mock_dashboard.slices = [Mock(id=10)]
mock_dashboard.position_json = json.dumps(
{
"ROOT_ID": {
"children": ["GRID_ID"],
"id": "ROOT_ID",
"type": "ROOT",
},
"GRID_ID": {
"children": ["TABS-abc123"],
"id": "GRID_ID",
"parents": ["ROOT_ID"],
"type": "GRID",
},
"TABS-abc123": {
"children": ["TAB-tab1", "TAB-tab2"],
"id": "TABS-abc123",
"parents": ["ROOT_ID", "GRID_ID"],
"type": "TABS",
},
"TAB-tab1": {
"children": ["ROW-existing"],
"id": "TAB-tab1",
"meta": {"text": "Activity Metrics"},
"parents": ["ROOT_ID", "GRID_ID", "TABS-abc123"],
"type": "TAB",
},
"TAB-tab2": {
"children": [],
"id": "TAB-tab2",
"meta": {"text": "Customers"},
"parents": ["ROOT_ID", "GRID_ID", "TABS-abc123"],
"type": "TAB",
},
"ROW-existing": {
"children": ["CHART-10"],
"id": "ROW-existing",
"meta": {"background": "BACKGROUND_TRANSPARENT"},
"parents": ["ROOT_ID", "GRID_ID", "TABS-abc123", "TAB-tab1"],
"type": "ROW",
},
"CHART-10": {
"id": "CHART-10",
"type": "CHART",
"parents": [
"ROOT_ID",
"GRID_ID",
"TABS-abc123",
"TAB-tab1",
"ROW-existing",
],
},
"DASHBOARD_VERSION_KEY": "v2",
}
)
mock_find_dashboard.return_value = mock_dashboard
mock_chart = _mock_chart(id=30, slice_name="Customer Chart")
mock_db_session.get.return_value = mock_chart
updated_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard")
updated_dashboard.slices = [Mock(id=10), Mock(id=30)]
mock_update_command.return_value.run.return_value = updated_dashboard
request = {"dashboard_id": 3, "chart_id": 30, "target_tab": "Customers"}
async with Client(mcp_server) as client:
result = await client.call_tool(
"add_chart_to_existing_dashboard", {"request": request}
)
assert result.structured_content["error"] is None
call_args = mock_update_command.call_args[0][1]
layout = json.loads(call_args["position_json"])
row_key = result.structured_content["position"]["row_key"]
assert row_key in layout
# Chart should be in TAB-tab2 (Customers), NOT TAB-tab1
assert row_key in layout["TAB-tab2"]["children"]
assert row_key not in layout["TAB-tab1"]["children"]
# GRID_ID should still only have TABS, not the new row
assert layout["GRID_ID"]["children"] == ["TABS-abc123"]
# Verify correct parent chain includes TAB-tab2
chart_parents = layout["CHART-30"]["parents"]
assert "TABS-abc123" in chart_parents
assert "TAB-tab2" in chart_parents
assert "TAB-tab1" not in chart_parents
@patch("superset.commands.dashboard.update.UpdateDashboardCommand")
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@patch("superset.db.session")
@@ -710,6 +881,63 @@ class TestLayoutHelpers:
}
assert _find_tab_insert_target(layout) == "TAB-first"
def test_find_tab_insert_target_by_tab_name(self):
"""Test _find_tab_insert_target resolves target_tab by display name."""
layout = {
"GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
"TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"},
"TAB-first": {
"children": [],
"type": "TAB",
"meta": {"text": "Activity Metrics"},
},
"TAB-second": {
"children": [],
"type": "TAB",
"meta": {"text": "Customers"},
},
}
assert _find_tab_insert_target(layout, target_tab="Customers") == "TAB-second"
def test_find_tab_insert_target_by_tab_id(self):
"""Test _find_tab_insert_target resolves target_tab by component ID."""
layout = {
"GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
"TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"},
"TAB-first": {
"children": [],
"type": "TAB",
"meta": {"text": "Tab 1"},
},
"TAB-second": {
"children": [],
"type": "TAB",
"meta": {"text": "Tab 2"},
},
}
assert _find_tab_insert_target(layout, target_tab="TAB-second") == "TAB-second"
def test_find_tab_insert_target_unmatched_falls_back_to_first(self):
"""Test _find_tab_insert_target falls back to first tab when target_tab
doesn't match any tab name or ID."""
layout = {
"GRID_ID": {"children": ["TABS-main"], "type": "GRID"},
"TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"},
"TAB-first": {
"children": [],
"type": "TAB",
"meta": {"text": "Tab 1"},
},
"TAB-second": {
"children": [],
"type": "TAB",
"meta": {"text": "Tab 2"},
},
}
assert (
_find_tab_insert_target(layout, target_tab="Nonexistent Tab") == "TAB-first"
)
def test_find_tab_insert_target_no_grid(self):
"""Test _find_tab_insert_target with missing GRID_ID."""
assert _find_tab_insert_target({"ROOT_ID": {"type": "ROOT"}}) is None
@@ -766,3 +994,55 @@ class TestLayoutHelpers:
assert "ROW-new" in layout["TAB-first"]["children"]
assert "ROW-new" not in layout["GRID_ID"]["children"]
class TestGenerateTitleFromCharts:
"""Tests for _generate_title_from_charts helper."""
def test_empty_list_returns_dashboard(self):
assert _generate_title_from_charts([]) == "Dashboard"
def test_single_chart(self):
charts = [_mock_chart(id=1, slice_name="Revenue")]
assert _generate_title_from_charts(charts) == "Revenue"
def test_two_charts_joined_with_ampersand(self):
charts = [
_mock_chart(id=1, slice_name="Revenue"),
_mock_chart(id=2, slice_name="Costs"),
]
assert _generate_title_from_charts(charts) == "Revenue & Costs"
def test_three_charts_joined_with_commas(self):
charts = [
_mock_chart(id=1, slice_name="Revenue"),
_mock_chart(id=2, slice_name="Costs"),
_mock_chart(id=3, slice_name="Profit"),
]
assert _generate_title_from_charts(charts) == "Revenue, Costs, Profit"
def test_four_charts_shows_plus_more(self):
charts = [_mock_chart(id=i, slice_name=f"Chart {i}") for i in range(1, 5)]
assert (
_generate_title_from_charts(charts) == "Chart 1, Chart 2, Chart 3 + 1 more"
)
def test_many_charts_shows_plus_more(self):
charts = [_mock_chart(id=i, slice_name=f"Chart {i}") for i in range(1, 8)]
assert (
_generate_title_from_charts(charts) == "Chart 1, Chart 2, Chart 3 + 4 more"
)
def test_charts_without_names_returns_dashboard(self):
chart = Mock()
chart.slice_name = None
assert _generate_title_from_charts([chart]) == "Dashboard"
def test_long_title_is_truncated(self):
charts = [
_mock_chart(id=1, slice_name="A" * 100),
_mock_chart(id=2, slice_name="B" * 100),
]
title = _generate_title_from_charts(charts)
assert len(title) <= 150
assert title.endswith("\u2026")