Compare commits

...

51 Commits

Author SHA1 Message Date
Enzo Martellucci
ce0fcf99a1 Merge branch 'master' into enxdev/chat-prototype 2026-06-17 10:05:10 +02:00
Xie Yanbo
a27ec1923e chore(export): Added ability to export chart YAML files with Unicode characters, fix #20331 (#28008)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:55:19 +01:00
serverdevil
3e2174b50f fix(database): enable superset_app_root override for databaseview link (#33508)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-06-16 20:24:49 -07:00
Gabriel Bourgeois
5b66443d48 fix(cli): inconsistent options for set-database-uri (#34893) 2026-06-16 17:50:51 -07:00
Korbinian Preisler
2ea7585490 chore(i18n): update German (de) translation (#40431)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:47:57 -07:00
Simon Rühle
eeac76146c fix(helm): add host alias to init job (#33968)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 17:44:47 -07:00
Shaitan
6a1091d576 fix(sql): broaden mutating-statement detection in SQL Lab parser (#40421)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: sha174n <pedro.sousa@preset.io>
2026-06-16 15:07:34 -07:00
Jakub Hrubý
8e82b6b2c3 fix(translation): loading translations in menu (#35640)
Co-authored-by: Jakub Hrubý <jakub.hruby@orgis.cz>
Co-authored-by: Jezevec <panjzvc@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-16 14:35:32 -07:00
Evan Rusackas
b0c5f99007 fix(oracle): replace deprecated cx-Oracle extra with oracledb (#41122)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 14:32:11 -07:00
Elizabeth Thompson
f1ae683923 fix(deps): replace deprecated np.NaN with np.nan (#41118)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:19:08 -07:00
dependabot[bot]
d51d98891e chore(deps): bump flask-migrate from 3.1.0 to 4.1.0 (#41011)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:18:08 -07:00
dependabot[bot]
1f95a6c486 chore(deps): bump simplejson from 3.20.1 to 4.1.1 (#41082)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:00:42 -07:00
dependabot[bot]
e93cbd6c38 chore(deps): bump croniter from 6.0.0 to 6.2.2 (#41086)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:59:00 -07:00
dependabot[bot]
dca8af770c chore(deps-dev): bump typescript-eslint from 8.60.1 to 8.61.0 in /superset-websocket (#41087)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:58:39 -07:00
dependabot[bot]
81c1181519 chore(deps-dev): bump typescript-eslint from 8.60.1 to 8.61.0 in /docs (#41092)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:56:44 -07:00
dependabot[bot]
387c62919e chore(deps): bump hot-shots from 15.0.0 to 16.0.0 in /superset-websocket (#41107)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:56:22 -07:00
dependabot[bot]
77d7483f27 chore(deps-dev): bump @formatjs/intl-durationformat from 0.10.13 to 0.10.14 in /superset-frontend (#41109)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:54:22 -07:00
dependabot[bot]
1a8d08152d chore(deps): bump fuse.js from 7.4.1 to 7.4.2 in /superset-frontend (#41110)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:54:06 -07:00
Bob Jo
257dafeec5 fix(query): don't mutate ad-hoc ORDER BY expressions when building queries (#40993)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 13:03:39 -04:00
Michael S. Molina
d88a6730cd chore: Chat extension improvements (#41117) 2026-06-16 11:58:36 -03:00
Michael S. Molina
ec623f3b93 Merge branch 'master' into enxdev/chat-prototype 2026-06-16 09:28:23 -03:00
Alexandru Soare
6d08e79259 feat(security): Add extension hooks for custom access control, ownership, and asset lifecycle (#40707) 2026-06-16 15:25:03 +03:00
Michael S. Molina
395bbb9611 chore: More cleanup of chat extension code (#41116) 2026-06-16 09:14:08 -03:00
Geidō
01ed81785e fix(dashboard): required filters reliably apply default + Apply enables on change (#40470) 2026-06-16 11:23:05 +03:00
Vighnesh Tule
7b4efacbc2 fix(charts): add default padding to match other charts (#36895)
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-15 21:05:17 -07:00
Amin Ghadersohi
7cb4990403 feat(mcp): add create_dataset tool to register physical tables as datasets (#40340)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:25:29 -04:00
dependabot[bot]
c90b2571d7 chore(deps-dev): bump xlrd from 2.0.1 to 2.0.2 (#41083)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:19:43 -07:00
dependabot[bot]
1a4941eee5 chore(deps-dev): bump hdbcli from 2.28.20 to 2.28.21 (#41084)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:19:33 -07:00
dependabot[bot]
d839cca995 chore(deps-dev): update pyocient requirement from <2,>=1.0.15 to >=1.0.15,<4 (#40941)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 18:18:25 -07:00
dependabot[bot]
0ec7e7df99 chore(deps): bump dompurify from 3.4.8 to 3.4.9 in /superset-frontend (#41089)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:16:36 -07:00
dependabot[bot]
9d8287e1bd chore(deps-dev): bump @typescript-eslint/parser from 8.60.1 to 8.61.0 in /superset-websocket (#41090)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:16:21 -07:00
dependabot[bot]
0c696cea7e chore(deps): bump google-auth-library from 10.6.2 to 10.7.0 in /superset-frontend (#41091)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:16:05 -07:00
dependabot[bot]
fe625a917e chore(deps-dev): bump @typescript-eslint/parser from 8.60.1 to 8.61.0 in /docs (#41093)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:14:51 -07:00
dependabot[bot]
a69f9eb00d chore(deps-dev): bump oxlint from 1.68.0 to 1.69.0 in /superset-frontend (#41094)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:14:27 -07:00
Evan Rusackas
1311d040ba feat(deckgl): add point radius controls for GeoJSON layer (#33247)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-15 17:38:43 -07:00
Evan Rusackas
6e2db42d98 chore(lint): convert dashboard components to function components (#39460)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-06-15 16:39:12 -07:00
yousoph
28aedc82c3 fix(upload): database field shows validation warning after selecting a database (#41078) 2026-06-15 16:38:24 -07:00
Evan Rusackas
f56524bb71 chore(frontend): remove unused modules flagged by knip (#41072)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:38:00 -07:00
Evan Rusackas
4ae9980e4c chore(ci): remove unused Claude PR Assistant workflow (#41081)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 16:37:39 -07:00
Amin Ghadersohi
c1b5d05f83 fix(bigquery): set default dataset from schema in adjust_engine_params (#40776) 2026-06-15 18:37:06 -04:00
Evan Rusackas
e16bb29faf fix(embedded): allow guests to apply a Time Grain native filter (#32768) (#41017)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 15:22:21 -07:00
Elizabeth Thompson
09b4bc51a3 fix(charts): rename deprecated query object fields in schema before QueryObject construction (#41056) 2026-06-15 14:45:41 -07:00
Enzo Martellucci
5c1609e3f9 chore(extensions): align naming conventions 2026-06-15 17:46:42 +02:00
Enzo Martellucci
715c07b5c7 chore(extensions): remove out-of-scope scaffolding from chat SIP branch 2026-06-15 15:27:24 +02:00
Michael S. Molina
a1eba0f9a1 chore: Cleanup changes in chat feature branch (#41008) 2026-06-12 16:58:21 -03:00
Enzo Martellucci
568337f370 feat(extensions): add dedicated chat contribution type (#41000) 2026-06-12 20:54:13 +02:00
Enzo Martellucci
f170dc1d9e refactor(chatbot): drop extension settings layer; resolve last-loaded chatbot (#40968) 2026-06-11 13:32:03 +02:00
Enzo Martellucci
09c09f3f6b refactor: rename chatbot registration location 2026-06-10 14:32:52 +02:00
Enzo Martellucci
c65c9523aa refactor(extensions): remove install/lifecycle/dependency machinery (#40916) 2026-06-09 22:20:40 +02:00
Enzo Martellucci
94e0071883 Merge branch 'master' into enxdev/chat-prototype
Bring the chatbot extension feature branch up to date with master. The
chatbot work lives in new paths (superset/extensions/*, the core chatbot
namespace, ChatbotMount, superset-core namespaces) and merged cleanly with
no conflicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:55:53 +02:00
Enzo Martellucci
380e70060b feat(extensions): define the superset.chatbot contribution point (#40439) 2026-06-08 22:50:34 +02:00
133 changed files with 10740 additions and 8280 deletions

View File

@@ -3,10 +3,6 @@ enable-beta-ecosystems: true
updates:
- package-ecosystem: "github-actions"
directory: "/"
ignore:
# Ignore temporarily as release schedule is too mentally taxing for dep-handling maintainers
# Additionally, very few PRs are reviewed by this action.
- dependency-name: anthropics/claude-code-action
schedule:
interval: "daily"
cooldown:

View File

@@ -1,88 +0,0 @@
name: Claude PR Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
permissions:
contents: read
jobs:
check-permissions:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude'))
runs-on: ubuntu-latest
outputs:
allowed: ${{ steps.check.outputs.allowed }}
steps:
- name: Check if user is allowed
id: check
env:
COMMENTER: ${{ github.event.comment.user.login }}
run: |
# List of allowed users
ALLOWED_USERS="mistercrunch,rusackas"
echo "Checking permissions for user: $COMMENTER"
# Check if user is in allowed list
if [[ ",$ALLOWED_USERS," == *",$COMMENTER,"* ]]; then
echo "allowed=true" >> $GITHUB_OUTPUT
echo "✅ User $COMMENTER is allowed to use Claude"
else
echo "allowed=false" >> $GITHUB_OUTPUT
echo "❌ User $COMMENTER is not allowed to use Claude"
fi
deny-access:
needs: check-permissions
if: needs.check-permissions.outputs.allowed == 'false'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Comment access denied
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COMMENTER_LOGIN: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
with:
script: |
const commenter = process.env.COMMENTER_LOGIN;
const message = `👋 Hi @${commenter}!
Thanks for trying to use Claude Code, but currently only certain team members have access to this feature.
If you believe you should have access, please contact a project maintainer.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
claude-code-action:
needs: check-permissions
if: needs.check-permissions.outputs.allowed == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@5fb899572b81d2bb648d4d187173a2f423a9677c # beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"

View File

@@ -101,7 +101,7 @@
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.60.1",
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
@@ -109,7 +109,7 @@
"globals": "^17.6.0",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.60.1",
"typescript-eslint": "^8.61.0",
"webpack": "^5.107.2"
},
"browserslist": {

View File

@@ -7235,10 +7235,10 @@
"pypi_packages": [
"oracledb"
],
"connection_string": "oracle://{username}:{password}@{hostname}:{port}",
"connection_string": "oracle+oracledb://{username}:{password}@{hostname}:{port}",
"default_port": 1521,
"notes": "Previously used cx_Oracle, now uses oracledb.",
"docs_url": "https://cx-oracle.readthedocs.io/en/latest/user_guide/installation.html",
"docs_url": "https://python-oracledb.readthedocs.io/en/latest/user_guide/installation.html",
"category": "Other Databases"
},
"engine": "oracle",

View File

@@ -4922,110 +4922,110 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.60.1", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz#c1060bb8fa4be80624d3f3dec8dd9caca373af76"
integrity sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==
"@typescript-eslint/eslint-plugin@8.61.0", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz#db20271974b94a3a54d3b9544e5f5b3481448400"
integrity sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/type-utils" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/scope-manager" "8.61.0"
"@typescript-eslint/type-utils" "8.61.0"
"@typescript-eslint/utils" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.60.1", "@typescript-eslint/parser@^8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.1.tgz#a9d7f30850384d34b41f4687dd8944823c09e289"
integrity sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==
"@typescript-eslint/parser@8.61.0", "@typescript-eslint/parser@^8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.61.0.tgz#1afe73c9ccce16b7a26d6b95f9400b0ccc34af87"
integrity sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==
dependencies:
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/scope-manager" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.1.tgz#eb29712f58d72c222fc727162e92f2ab4670971b"
integrity sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==
"@typescript-eslint/project-service@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.61.0.tgz#417a2feac32e8ebd336d63f068c3b42b736ea1ac"
integrity sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.60.1"
"@typescript-eslint/types" "^8.60.1"
"@typescript-eslint/tsconfig-utils" "^8.61.0"
"@typescript-eslint/types" "^8.61.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz#2f875962eaad0a0789cc3c36aea9b4ddeb2dd9c8"
integrity sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==
"@typescript-eslint/scope-manager@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz#93c2520d05653fe65eb9ee98efc74fd0134a7852"
integrity sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==
dependencies:
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
"@typescript-eslint/tsconfig-utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
"@typescript-eslint/tsconfig-utils@^8.60.1":
"@typescript-eslint/tsconfig-utils@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz#05d6e3ff20001674ebcd22d03dac29ee448043ba"
integrity sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==
"@typescript-eslint/type-utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz#1ae45f0f2a701354beea4a58c2161e40a5e3c379"
integrity sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==
"@typescript-eslint/tsconfig-utils@^8.61.0":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz#ca88080e0cf191d49516d7f300b67aa090d2254f"
integrity sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==
"@typescript-eslint/type-utils@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz#50219b57e6b89cecfb1a15f093b15ec9ee019974"
integrity sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==
dependencies:
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/utils" "8.61.0"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
"@typescript-eslint/types@^8.60.1":
"@typescript-eslint/types@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.0.tgz#0ddb46e012a4288292950bdd253db42f278ce64d"
integrity sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==
"@typescript-eslint/typescript-estree@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz#016630b119228bf483ddc652703a6a038f3fdd74"
integrity sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==
"@typescript-eslint/types@^8.61.0":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.1.tgz#0c51f518e4e6848371a1c988e859d59eb7522d5a"
integrity sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==
"@typescript-eslint/typescript-estree@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz#98ca47260bbf627fc28f018b3a0abf00e3090690"
integrity sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==
dependencies:
"@typescript-eslint/project-service" "8.60.1"
"@typescript-eslint/tsconfig-utils" "8.60.1"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/project-service" "8.61.0"
"@typescript-eslint/tsconfig-utils" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.1.tgz#31cf566095602d9fe8ad91837d2eb520b8de762b"
integrity sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==
"@typescript-eslint/utils@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.61.0.tgz#ed3546a052787e84ea6c5064d0919fc5eea8522f"
integrity sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.60.1"
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/scope-manager" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/visitor-keys@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz#165d1d8901137b944efaf18f00ab5ecb57f06995"
integrity sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==
"@typescript-eslint/visitor-keys@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz#39b4e1ab8936d23bea973d39fd092f9aa21f275e"
integrity sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==
dependencies:
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/types" "8.61.0"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
@@ -14499,15 +14499,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.60.1:
version "8.60.1"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.60.1.tgz#13db05c6eabb89669deec44545b788a0e9aee640"
integrity sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==
typescript-eslint@^8.61.0:
version "8.61.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.61.0.tgz#6927fb94f5f29623e370d33fd9fa61f15d6d996b"
integrity sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==
dependencies:
"@typescript-eslint/eslint-plugin" "8.60.1"
"@typescript-eslint/parser" "8.60.1"
"@typescript-eslint/typescript-estree" "8.60.1"
"@typescript-eslint/utils" "8.60.1"
"@typescript-eslint/eslint-plugin" "8.61.0"
"@typescript-eslint/parser" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/utils" "8.61.0"
typescript@~6.0.3:
version "6.0.3"

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.16.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.16.1 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.16.0](https://img.shields.io/badge/Version-0.16.0-informational?style=flat-square)
![Version: 0.16.1](https://img.shields.io/badge/Version-0.16.1-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application

View File

@@ -62,6 +62,9 @@ spec:
{{- if .Values.init.initContainers }}
initContainers: {{- tpl (toYaml .Values.init.initContainers) . | nindent 6 }}
{{- end }}
{{- with .Values.hostAliases }}
hostAliases: {{- toYaml . | nindent 6 }}
{{- end }}
containers:
- name: {{ template "superset.name" . }}-init-db
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"

View File

@@ -43,7 +43,7 @@ dependencies = [
"click-option-group",
"colorama",
"flask-cors>=6.0.0, <7.0",
"croniter>=0.3.28",
"croniter>=6.2.2",
"cron-descriptor",
"cryptography>=42.0.4, <47.0.0",
"deprecation>=2.1.0, <2.2.0",
@@ -53,7 +53,7 @@ dependencies = [
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
"flask-login>=0.6.0, < 1.0",
"flask-migrate>=3.1.0, <5.0",
"flask-migrate>=4.1.0, <5.0",
"flask-session>=0.4.0, <1.0",
"flask-wtf>=1.3.0, <2.0",
"geopy",
@@ -97,7 +97,7 @@ dependencies = [
"selenium>=4.44.0, <5.0",
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=3.15.0",
"simplejson>=4.1.1",
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
@@ -144,7 +144,7 @@ dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
exasol = ["sqlalchemy-exasol>=2.4.0, <8.0"]
excel = ["xlrd>=1.2.0, <1.3"]
excel = ["xlrd>=2.0.2, <2.1"]
fastmcp = [
"fastmcp>=3.2.4,<4.0",
# tiktoken backs the response-size-guard token estimator. Without
@@ -156,7 +156,7 @@ firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=26.4.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
hana = ["hdbcli==2.28.20", "sqlalchemy_hana==0.4.0"]
hana = ["hdbcli==2.28.21", "sqlalchemy_hana==0.4.0"]
hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7.0",
@@ -173,11 +173,11 @@ motherduck = ["apache-superset[duckdb]"]
mysql = ["mysqlclient>=2.1.0, <3"]
ocient = [
"sqlalchemy-ocient>=1.0.0",
"pyocient>=1.0.15, <2",
"pyocient>=1.0.15, <4",
"shapely",
"geojson",
]
oracle = ["cx-Oracle>8.0.0, <8.4"]
oracle = ["oracledb>=2.0.0, <5"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.60.0, <2"]

View File

@@ -84,7 +84,7 @@ colorama==0.4.6
# flask-appbuilder
cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.0.0
croniter==6.2.2
# via apache-superset (pyproject.toml)
cryptography==46.0.7
# via
@@ -141,7 +141,7 @@ flask-login==0.6.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
flask-migrate==3.1.0
flask-migrate==4.1.0
# via apache-superset (pyproject.toml)
flask-session==0.8.0
# via apache-superset (pyproject.toml)
@@ -384,7 +384,7 @@ setuptools==80.9.0
# via -r requirements/base.in
shillelagh==1.4.4
# via apache-superset (pyproject.toml)
simplejson==3.20.1
simplejson==4.1.1
# via apache-superset (pyproject.toml)
six==1.17.0
# via

View File

@@ -174,7 +174,7 @@ cron-descriptor==1.4.5
# via
# -c requirements/base-constraint.txt
# apache-superset
croniter==6.0.0
croniter==6.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -293,7 +293,7 @@ flask-login==0.6.3
# -c requirements/base-constraint.txt
# apache-superset
# flask-appbuilder
flask-migrate==3.1.0
flask-migrate==4.1.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -939,7 +939,7 @@ shillelagh==1.4.4
# via
# -c requirements/base-constraint.txt
# apache-superset
simplejson==3.20.1
simplejson==4.1.1
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -95,14 +95,14 @@
"echarts": "^5.6.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fuse.js": "^7.4.1",
"fuse.js": "^7.4.2",
"geolib": "^3.3.14",
"geostyler": "^18.6.0",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.6.2",
"google-auth-library": "^10.7.0",
"immer": "^11.1.8",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -178,7 +178,7 @@
"@babel/types": "^7.29.7",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.13",
"@formatjs/intl-durationformat": "^0.10.14",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.60.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
@@ -261,7 +261,7 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.68.0",
"oxlint": "^1.69.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
@@ -3955,38 +3955,38 @@
}
},
"node_modules/@formatjs/bigdecimal": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.5.tgz",
"integrity": "sha512-2XTKNrZRaCUyXK2976wfutqxMBuPO/S/zbJnQdysLI2Zy5mWPVNVEkE6tsTcSVWSE7DgO88t8DtBy+uf3I8bxg==",
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.6.tgz",
"integrity": "sha512-aPzKsGQOkQRHUEbyO/ZtYfr4EqaBQnSs6U4tzTla1xBnIdEHgY2GqEqso28UMwWRkzKqqTj5+/6BmuOsRkfn2A==",
"dev": true,
"license": "MIT"
},
"node_modules/@formatjs/fast-memoize": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.5.tgz",
"integrity": "sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==",
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz",
"integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@formatjs/intl-durationformat": {
"version": "0.10.13",
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.13.tgz",
"integrity": "sha512-A1dBcOh1YrcRf/AbmZHFVXgIYkpAaFgyGaYavO/KutbqEXY3HI63o2E1ctmxmllfg3qn3TZGtZux42EFwHNTbg==",
"version": "0.10.14",
"resolved": "https://registry.npmjs.org/@formatjs/intl-durationformat/-/intl-durationformat-0.10.14.tgz",
"integrity": "sha512-qVrbKGJZwoGFLmQyMBn4Pk44WCpBKINwLf+isrEd4RpvKs5nChRofHanWJqSu8TjuKJjk1qJjDIFkA/hJX/a9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/bigdecimal": "0.2.5",
"@formatjs/intl-localematcher": "0.8.9"
"@formatjs/bigdecimal": "0.2.6",
"@formatjs/intl-localematcher": "0.8.10"
}
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.8.9",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.9.tgz",
"integrity": "sha512-GmB0F/gYh4Hdl4rLWjgDsgT+x4pB54fkJeRh8kAZ4XFzKeCK8dGs+SBJWXO42QZtOUni+IDWKNuCw6wiL4lTvw==",
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.10.tgz",
"integrity": "sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "3.1.5"
"@formatjs/fast-memoize": "3.1.6"
}
},
"node_modules/@gar/promise-retry": {
@@ -8324,9 +8324,9 @@
]
},
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.68.0.tgz",
"integrity": "sha512-wEdsIspexXLLMCPAEOcCuFLMt6aE3AzTuA/nQKLPRnoJ+EQTturmGheDkhHuuVHx0GbutjQ3JKmEn+Gz6Ag28Q==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.69.0.tgz",
"integrity": "sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==",
"cpu": [
"arm"
],
@@ -8341,9 +8341,9 @@
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.68.0.tgz",
"integrity": "sha512-6aZRNNXQTsYtgaus8HTb9nuCcsrQTlKXGnktwvwW0n/SooRWNxNb3925grDkC63aEYZuCIyOVLV16IdYIoC2aQ==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.69.0.tgz",
"integrity": "sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==",
"cpu": [
"arm64"
],
@@ -8358,9 +8358,9 @@
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.68.0.tgz",
"integrity": "sha512-lVTbsE3kO4bLpZELgjRZuAJc8kP98wb83yMXWH8gaPaFZ+cM2IDeZto4ByoUAYj0Mxv2rvw+A1ssZequSepVSg==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.69.0.tgz",
"integrity": "sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==",
"cpu": [
"arm64"
],
@@ -8375,9 +8375,9 @@
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.68.0.tgz",
"integrity": "sha512-nCmw2XrmQskjBUh/sfP5yKs93V68LijQgjd1cuuZ/q4SCARngLYs60/qqyzuMsg8QQ9KArDI98hxs/RDGE4KRQ==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.69.0.tgz",
"integrity": "sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==",
"cpu": [
"x64"
],
@@ -8392,9 +8392,9 @@
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.68.0.tgz",
"integrity": "sha512-TI4ovQJliYE9V6e06cEv+qEI9uj7Ao65fmif4er4HD+aouyYyh0P31q2jh3KtqsOHHcQqv2PZ61TjJFLpBDGWQ==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.69.0.tgz",
"integrity": "sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==",
"cpu": [
"x64"
],
@@ -8409,9 +8409,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.68.0.tgz",
"integrity": "sha512-LcNnEi9g71Cmry5ZpLbKT+oVv+/zYG3hYVAbBBB5X85nOQZSk8l92CnDkxJMcxUg0NCnMCOFZuaVDlMyv4tYJw==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.69.0.tgz",
"integrity": "sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==",
"cpu": [
"arm"
],
@@ -8426,9 +8426,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.68.0.tgz",
"integrity": "sha512-OovHahL3FX4UaK+hgSf11llUx2vszqjSdQQ61Ck9InOEI/ptZoC4XSQJurITqItVvd53JSlmkLMeaNjM1PoQew==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.69.0.tgz",
"integrity": "sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==",
"cpu": [
"arm"
],
@@ -8443,9 +8443,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.68.0.tgz",
"integrity": "sha512-YbzTglnHLzzi9zv5or8Ztz5fykAoZE8W9iM42/bOrF4HBSB6rJTqdLQWuoP76EHQw9DuKl76K1QmFlG29sPJXQ==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.69.0.tgz",
"integrity": "sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==",
"cpu": [
"arm64"
],
@@ -8460,9 +8460,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.68.0.tgz",
"integrity": "sha512-qVKtCZNic+OoNnOr/hCQAu22HSQzflI7Fsq/Blzkw02SnLuv163k3kfmrVpZjSBlUHgsRKj6WgQiw30d3SX02Q==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.69.0.tgz",
"integrity": "sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==",
"cpu": [
"arm64"
],
@@ -8477,9 +8477,9 @@
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.68.0.tgz",
"integrity": "sha512-zExyZ8ZOUuAyQ0y9jpTcyjKUz62YY9JhKPyVxzvjTpXzZ3ujdqiVwfPWDdnA1SsIOrxdtxHn7KErDHLWskFjXg==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.69.0.tgz",
"integrity": "sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==",
"cpu": [
"ppc64"
],
@@ -8494,9 +8494,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.68.0.tgz",
"integrity": "sha512-6C4MPuwewyDavA7sxM14wzgRi5GGL68HPIxRCdVyS75U4MDbpFVYzKO9WNR6KLKTMPq2pcz3THwo1sK2uiqngw==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.69.0.tgz",
"integrity": "sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==",
"cpu": [
"riscv64"
],
@@ -8511,9 +8511,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.68.0.tgz",
"integrity": "sha512-bnZooVeHAcvA+dH0EDLgx+7HY/DRi6e0hFszg3P+OBatuUjV6EvfIyNIzWOusmqAVh4L6r21GGTZtiKE4iqM4Q==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.69.0.tgz",
"integrity": "sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==",
"cpu": [
"riscv64"
],
@@ -8528,9 +8528,9 @@
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.68.0.tgz",
"integrity": "sha512-dIqnZnJSmHCMOUpUcWQOiV14o3DDPVx1DSsMaSzvdhNjC1tB1iEPZbdiMSCIEYbkgbsYznHXWqFdKL8WUB3F8g==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.69.0.tgz",
"integrity": "sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==",
"cpu": [
"s390x"
],
@@ -8545,9 +8545,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.68.0.tgz",
"integrity": "sha512-zc9lEnfV/HreDTY6gdMlZe+irkwHSxQ4/B1pS9GyK7RVaA5LxhoZY/w6/o2vIwLLEYiXQ5ujGxOM1ZazeFAAIA==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.69.0.tgz",
"integrity": "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==",
"cpu": [
"x64"
],
@@ -8562,9 +8562,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.68.0.tgz",
"integrity": "sha512-Dl5QEX0TCo/40Cdh1o1JdPS//+YiWqjC+Hrrya5OQmStZZr4svAFtdlqcpCrU9yq2Mo3vRVyO9B3h0dzD8s36Q==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.69.0.tgz",
"integrity": "sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==",
"cpu": [
"x64"
],
@@ -8579,9 +8579,9 @@
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.68.0.tgz",
"integrity": "sha512-/qy6dOvi4S3/LeXq0l5BT5pRKPYA7oj3uKwJOAZOr5HRLL+HK6jdBynvWuXIA2wwfE01RzNYmbBdM7vwYx00sA==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.69.0.tgz",
"integrity": "sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==",
"cpu": [
"arm64"
],
@@ -8596,9 +8596,9 @@
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.68.0.tgz",
"integrity": "sha512-fHNtVqPHSYE7UFDSLVFUjxQjnSVXxseNJmRW+XuP4pXXDwePdPda43NL7/BBCFTxHjycOc44JNDaOPtFDNui9A==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.69.0.tgz",
"integrity": "sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==",
"cpu": [
"arm64"
],
@@ -8613,9 +8613,9 @@
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.68.0.tgz",
"integrity": "sha512-NnKXr4Wgo4nps3erhrE0f8shBvBPZMHg72nDsvX0JyrRvsNiP3f1JNvbCKh+A6VFvpF7ZoJxu904P3cKMhvZnA==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.69.0.tgz",
"integrity": "sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==",
"cpu": [
"ia32"
],
@@ -8630,9 +8630,9 @@
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.68.0.tgz",
"integrity": "sha512-zg5pA+84AlU6XHJ3ruiRxziO71QTrz8nLsk6u01JGS5+tL9/bnlakFiklFrcy4R1/V7ktWtaNitN3JZWmKnf6g==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.69.0.tgz",
"integrity": "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==",
"cpu": [
"x64"
],
@@ -20887,9 +20887,9 @@
}
},
"node_modules/fuse.js": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.1.tgz",
"integrity": "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw==",
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.2.tgz",
"integrity": "sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
@@ -21856,9 +21856,9 @@
}
},
"node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz",
"integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
@@ -26108,6 +26108,21 @@
}
}
},
"node_modules/jsdom/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/jsdom/node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -32587,9 +32602,9 @@
}
},
"node_modules/oxlint": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.68.0.tgz",
"integrity": "sha512-dXcbq+xsmLrMy6T8d0euf3IYUfLmjHIE11pOxiUSi5LHkFZaYPv568R6sEjcavVpUxoaQe66UBuK4HEi74NxpA==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.69.0.tgz",
"integrity": "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -32602,25 +32617,25 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.68.0",
"@oxlint/binding-android-arm64": "1.68.0",
"@oxlint/binding-darwin-arm64": "1.68.0",
"@oxlint/binding-darwin-x64": "1.68.0",
"@oxlint/binding-freebsd-x64": "1.68.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.68.0",
"@oxlint/binding-linux-arm-musleabihf": "1.68.0",
"@oxlint/binding-linux-arm64-gnu": "1.68.0",
"@oxlint/binding-linux-arm64-musl": "1.68.0",
"@oxlint/binding-linux-ppc64-gnu": "1.68.0",
"@oxlint/binding-linux-riscv64-gnu": "1.68.0",
"@oxlint/binding-linux-riscv64-musl": "1.68.0",
"@oxlint/binding-linux-s390x-gnu": "1.68.0",
"@oxlint/binding-linux-x64-gnu": "1.68.0",
"@oxlint/binding-linux-x64-musl": "1.68.0",
"@oxlint/binding-openharmony-arm64": "1.68.0",
"@oxlint/binding-win32-arm64-msvc": "1.68.0",
"@oxlint/binding-win32-ia32-msvc": "1.68.0",
"@oxlint/binding-win32-x64-msvc": "1.68.0"
"@oxlint/binding-android-arm-eabi": "1.69.0",
"@oxlint/binding-android-arm64": "1.69.0",
"@oxlint/binding-darwin-arm64": "1.69.0",
"@oxlint/binding-darwin-x64": "1.69.0",
"@oxlint/binding-freebsd-x64": "1.69.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.69.0",
"@oxlint/binding-linux-arm-musleabihf": "1.69.0",
"@oxlint/binding-linux-arm64-gnu": "1.69.0",
"@oxlint/binding-linux-arm64-musl": "1.69.0",
"@oxlint/binding-linux-ppc64-gnu": "1.69.0",
"@oxlint/binding-linux-riscv64-gnu": "1.69.0",
"@oxlint/binding-linux-riscv64-musl": "1.69.0",
"@oxlint/binding-linux-s390x-gnu": "1.69.0",
"@oxlint/binding-linux-x64-gnu": "1.69.0",
"@oxlint/binding-linux-x64-musl": "1.69.0",
"@oxlint/binding-openharmony-arm64": "1.69.0",
"@oxlint/binding-win32-arm64-msvc": "1.69.0",
"@oxlint/binding-win32-ia32-msvc": "1.69.0",
"@oxlint/binding-win32-x64-msvc": "1.69.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=0.22.1",
@@ -43220,6 +43235,21 @@
}
}
},
"node_modules/whatwg-url/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/whatwg-url/node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
@@ -44462,7 +44492,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"dompurify": "^3.4.9",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
@@ -44583,9 +44613,9 @@
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -44946,7 +44976,7 @@
"dependencies": {
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"dompurify": "^3.4.8",
"dompurify": "^3.4.9",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
@@ -44962,9 +44992,9 @@
}
},
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"

View File

@@ -178,14 +178,14 @@
"echarts": "^5.6.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fuse.js": "^7.4.1",
"fuse.js": "^7.4.2",
"geolib": "^3.3.14",
"geostyler": "^18.6.0",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.6.2",
"google-auth-library": "^10.7.0",
"immer": "^11.1.8",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -261,7 +261,7 @@
"@babel/types": "^7.29.7",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.13",
"@formatjs/intl-durationformat": "^0.10.14",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.60.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
@@ -344,7 +344,7 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.68.0",
"oxlint": "^1.69.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",

View File

@@ -18,6 +18,14 @@
"types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/index.js"
},
"./chat": {
"types": "./lib/chat/index.d.ts",
"default": "./lib/chat/index.js"
},
"./navigation": {
"types": "./lib/navigation/index.d.ts",
"default": "./lib/navigation/index.js"
},
"./commands": {
"types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js"

View File

@@ -0,0 +1,184 @@
/**
* 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.
*/
/**
* @fileoverview Chat contribution API for Superset extensions.
*
* Chat is a dedicated contribution type (not a view): an extension registers
* a chat via {@link registerChat} and the host owns where and how it is
* mounted. The host applies singleton resolution — multiple chat extensions
* may register, but exactly one is active at a time.
*
* @example
* ```typescript
* import { chat } from '@apache-superset/core';
*
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* () => <AcmeTrigger />,
* () => <AcmePanel />,
* );
* ```
*/
import { ReactElement } from 'react';
import type { Disposable, Event } from '../common';
export interface Chat {
/** The unique identifier for the chat. */
id: string;
/** The display name of the chat. */
name: string;
/** Optional description of the chat, for display in contribution manifests. */
description?: string;
}
export type DisplayMode = 'floating' | 'panel';
/**
* Registers a chat provider. The host applies singleton resolution — only one
* chat is active at a time: the most recently registered chat wins, and
* disposing it restores the previously registered one. Re-registering an id
* replaces that registration in place.
*
* When a registration with a different id takes over the active slot (or the
* active chat is disposed), the host closes the panel first, firing
* {@link onDidClose}; an in-place same-id replacement keeps the open state.
*
* Disposing the returned Disposable unregisters the chat.
*
* @param chat The chat descriptor (id, name).
* @param trigger A function returning the collapsed bubble element. Owned by
* the extension — dynamic state such as unread counts and badges lives here.
* Hidden by the host when in panel mode.
* @param panel A function returning the chat panel element. Mounted by the
* host as a floating overlay in 'floating' mode, or docked at the side of
* the viewport in 'panel' mode (the reference host docks a fixed-width
* overlay at the right edge; hosts may integrate a true layout slot
* instead). Same component in both modes.
* @returns A Disposable that unregisters the chat when disposed.
*
* @example
* ```typescript
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* () => <AcmeTrigger />,
* () => <AcmePanel />,
* );
* ```
*/
export declare function registerChat(
chat: Chat,
trigger: () => ReactElement,
panel: () => ReactElement,
): Disposable;
/**
* Returns the active chat descriptor.
*
* @returns A copy of the active Chat descriptor, or undefined if none is
* registered. Mutating the returned object has no effect on the registry.
*/
export declare function getChat(): Chat | undefined;
/**
* Event fired when a chat is registered.
*/
export declare const onDidRegisterChat: Event<Chat>;
/**
* Event fired when a chat is unregistered.
*/
export declare const onDidUnregisterChat: Event<Chat>;
/**
* Opens the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when no chat is registered or the panel is already open.
*/
export declare function open(): void;
/**
* Closes the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when the panel is not open.
*/
export declare function close(): void;
/**
* Returns whether the active chat's panel is currently open.
*
* @returns True if the chat panel is open.
*/
export declare function isOpen(): boolean;
/**
* Event fired when the chat panel opens. Also fired by the host's own
* controls, not only by an extension's open() call.
*/
export declare const onDidOpen: Event<void>;
/**
* Event fired when the chat panel closes. Also fired when the host closes the
* panel itself, e.g. because the active chat was disposed or displaced by a
* different chat.
*/
export declare const onDidClose: Event<void>;
/**
* Returns the current display mode.
*
* @returns The current DisplayMode.
*/
export declare function getDisplayMode(): DisplayMode;
/**
* Sets the display mode.
*
* The mode is host-global and applies to whichever chat is active, regardless
* of which extension calls it. Hosts may also change the mode through their
* own controls — use onDidChangeDisplayMode to observe all changes rather than
* assuming the last setDisplayMode() call won.
*
* @param displayMode The display mode to switch to.
*/
export declare function setDisplayMode(displayMode: DisplayMode): void;
/**
* Event fired when the display mode changes, whether triggered by an
* extension via setDisplayMode() or by host-provided controls.
*/
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
/**
* Event fired when the panel is resized in panel mode.
*
* The host owns the resizer handle and drag interaction; a host without a
* resizer never fires this event. (The reference host mounts the panel at a
* fixed width and does not provide a resizer, so subscribers receive no
* events there.) Listen to this event to adapt internal layout to the
* available width; do not rely on it firing.
*/
export declare const onDidResizePanel: Event<{ width: number }>;
// TODO: client actions API — tool availability functions will be added here
// once the client_actions SIP is finalized. The chat namespace is the
// intended integration point between the two SIPs.

View File

@@ -223,8 +223,6 @@ export interface Extension {
dependencies: string[];
/** Human-readable description of the extension */
description: string;
/** List of other extensions that this extension depends on */
extensionDependencies: string[];
/** Unique identifier for the extension */
id: string;
/** Human-readable name of the extension */

View File

@@ -23,9 +23,10 @@
* This module defines the aggregate interfaces used by the extension.json
* manifest and the `superset-extensions` build command. Individual metadata
* types are defined in their respective namespace modules (commands, views,
* menus, editors) and re-exported here for the manifest schema.
* menus, editors, chat) and re-exported here for the manifest schema.
*/
import { Chat } from '../chat';
import { Command } from '../commands';
import { View } from '../views';
import { Menu } from '../menus';
@@ -71,7 +72,8 @@ export interface MenuContributions {
}
/**
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
* Aggregates all contributions (commands, menus, views, editors, and chat)
* provided by an extension or module.
*/
export interface Contributions {
/** List of commands. */
@@ -82,4 +84,10 @@ export interface Contributions {
views: ViewContributions;
/** List of editors. */
editors?: Editor[];
/**
* The chat contributed by the extension — at most one per extension, since
* the host applies singleton resolution and renders exactly one active
* chat at a time.
*/
chat?: Chat;
}

View File

@@ -18,10 +18,12 @@
*/
export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
export * as navigation from './navigation';
export * as sqlLab from './sqlLab';
export * as views from './views';
export * as contributions from './contributions';

View File

@@ -0,0 +1,81 @@
/**
* 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.
*/
/**
* @fileoverview Navigation namespace for Superset extensions.
*
* Exposes the current application surface so extensions can react to route
* changes without polling. Entity-level context (chart, dashboard, dataset)
* is intentionally not included here — surface-specific namespaces that
* resolve entity payloads are introduced in later phases.
*/
import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
* single entity is active. `'sqllab'` is the SQL editor where
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
* the welcome surface and the fallback for any route not explicitly enumerated.
*/
export type Page =
| 'dashboard'
| 'dashboard_list'
| 'explore'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'home';
/**
* Returns the current page surface.
*
* @example
* ```typescript
* const page = navigation.getPage();
* if (page === 'dashboard') {
* // react to being on a dashboard surface
* }
* ```
*/
export declare function getPage(): Page;
/**
* Event fired whenever the user navigates to a different surface.
*
* @example
* ```typescript
* const sub = navigation.onDidChangePage(page => {
* if (page === 'dashboard') {
* // react to navigating onto a dashboard surface
* }
* });
* // later:
* sub.dispose();
* ```
*/
export declare const onDidChangePage: Event<Page>;

View File

@@ -43,7 +43,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"dompurify": "^3.4.9",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",

View File

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

View File

@@ -21,6 +21,7 @@ import { css, styled } from '@apache-superset/core/theme';
export default styled.div`
${({ theme }) => css`
/* Base table styles */
padding: ${theme.sizeUnit * 5}px;
table {
width: 100%;
min-width: auto;

View File

@@ -1613,8 +1613,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
pageSize={pageSize}
serverPaginationData={serverPaginationData}
pageSizeOptions={pageSizeOptions}
width={widthFromState}
height={heightFromState}
width={Math.max(0, widthFromState - theme.sizeUnit * 10)}
height={Math.max(0, heightFromState - theme.sizeUnit * 10)}
serverPagination={serverPagination}
onServerPaginationChange={handleServerPaginationChange}
onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)}

View File

@@ -17,7 +17,11 @@
* under the License.
*/
import type { ReactElement } from 'react';
import type { ControlPanelSectionConfig } from '@superset-ui/chart-controls';
import type {
ControlPanelSectionConfig,
CustomControlItem,
} from '@superset-ui/chart-controls';
import { isCustomControlItem } from '@superset-ui/chart-controls';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render } from '@testing-library/react';
import { SqlaFormData } from '@superset-ui/core';
@@ -28,6 +32,7 @@ import DeckGLGeoJson, {
computeGeoJsonIconOptionsFromJsOutput,
computeGeoJsonIconOptionsFromFormData,
getPoints,
getLayer,
} from './Geojson';
import controlPanel from './controlPanel';
@@ -295,3 +300,158 @@ test('DeckGLGeoJson falls back to legacy map_style when provider-specific style
}),
);
});
const baseFormData: SqlaFormData = {
datasource: 'test_datasource',
viz_type: 'deck_geojson',
slice_id: 1,
fill_color_picker: { r: 0, g: 0, b: 255, a: 1 },
stroke_color_picker: { r: 0, g: 0, b: 0, a: 1 },
};
const baseLayerArgs = {
onContextMenu: jest.fn(),
filterState: undefined,
setDataMask: jest.fn(),
payload: { data: { type: 'FeatureCollection', features: [] } },
setTooltip: jest.fn(),
emitCrossFilters: false,
};
test('getLayer preserves rendering for existing charts without new point radius fields', () => {
// Simulate form data from an existing chart that only has point_radius_scale
const legacyFormData = {
...baseFormData,
point_radius_scale: 200,
// point_radius and point_radius_units intentionally absent
};
const layer = getLayer({ formData: legacyFormData, ...baseLayerArgs });
const { props } = layer;
// Should match deck.gl defaults, NOT the new control panel defaults
expect(props.getPointRadius).toBe(1); // deck.gl default, not 10
expect(props.pointRadiusUnits).toBe('meters'); // deck.gl default, not 'pixels'
expect(props.pointRadiusScale).toBe(200); // user's saved value preserved
});
test('getLayer uses control panel defaults for new charts', () => {
const newChartFormData = {
...baseFormData,
point_radius: 10,
point_radius_units: 'pixels',
point_radius_scale: 1,
};
const layer = getLayer({ formData: newChartFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(10);
expect(props.pointRadiusUnits).toBe('pixels');
expect(props.pointRadiusScale).toBe(1);
});
test('getLayer falls back to defaults when legacy fields are null', () => {
// The old point_radius_scale control had `default: null`, so legacy charts
// can have null persisted; it must fall back to 1, not coerce to 0.
const nullFormData = {
...baseFormData,
point_radius: null,
point_radius_scale: null,
};
const layer = getLayer({ formData: nullFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(1);
expect(props.pointRadiusScale).toBe(1);
});
test('getLayer preserves an explicit zero radius scale', () => {
const zeroFormData = {
...baseFormData,
point_radius_scale: 0,
};
const layer = getLayer({ formData: zeroFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.pointRadiusScale).toBe(0);
});
test('getLayer coerces free-form string radius values to numbers', () => {
// Free-form SelectControls can store user-typed values as strings
const stringFormData = {
...baseFormData,
point_radius: '3',
point_radius_scale: '0.25',
};
const layer = getLayer({ formData: stringFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(3);
expect(props.pointRadiusScale).toBe(0.25);
});
type ControlConfig = {
default?: unknown;
validators?: unknown[];
choices?: [unknown, unknown][];
renderTrigger?: boolean;
};
const controlItems = controlPanel.controlPanelSections
.filter(
(s: ControlPanelSectionConfig | null): s is ControlPanelSectionConfig =>
s !== null,
)
.flatMap((section: ControlPanelSectionConfig) => section.controlSetRows)
.flat();
const findControlConfig = (name: string): ControlConfig | undefined =>
(controlItems.filter(isCustomControlItem) as CustomControlItem[]).find(
(item: CustomControlItem) => item.name === name,
)?.config as ControlConfig | undefined;
test('controlPanel exposes a Point Radius control defaulting to 10', () => {
const config = findControlConfig('point_radius');
expect(config).toBeDefined();
expect(config?.default).toBe(10);
expect(config?.renderTrigger).toBe(true);
expect(config?.validators).toHaveLength(1);
expect(config?.choices).toEqual(
expect.arrayContaining([
[1, '1'],
[10, '10'],
[100, '100'],
]),
);
});
test('controlPanel Point Radius Scale defaults to 1 with fractional choices', () => {
const config = findControlConfig('point_radius_scale');
expect(config).toBeDefined();
expect(config?.default).toBe(1);
expect(config?.renderTrigger).toBe(true);
expect(config?.validators).toHaveLength(1);
expect(config?.choices).toEqual(
expect.arrayContaining([
[0.1, '0.1'],
[1, '1'],
[10, '10'],
]),
);
});
test('controlPanel Point Radius Units defaults to pixels', () => {
const config = findControlConfig('point_radius_units');
expect(config).toBeDefined();
expect(config?.default).toBe('pixels');
expect(config?.renderTrigger).toBe(true);
expect(config?.choices?.map(([value]) => value)).toEqual([
'pixels',
'meters',
'common',
]);
});

View File

@@ -254,6 +254,15 @@ export const computeGeoJsonIconOptionsFromFormData = (
iconSizeUnits: fd.icon_size_unit,
});
// Free-form SelectControls can yield string values, and legacy charts may have
// null persisted for these fields, so coerce to a number (falling back to the
// provided default for null/undefined/NaN input, while preserving an explicit 0)
// before handing them to deck.gl's numeric layer props.
const toNumber = (value: unknown, fallback: number) => {
const num = Number(value ?? fallback);
return Number.isFinite(num) ? num : fallback;
};
export const getLayer: GetLayerType<GeoJsonLayer> = function ({
formData,
onContextMenu,
@@ -328,7 +337,11 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
getFillColor(feature, filterState?.value),
getLineColor,
getLineWidth: fd.line_width || 1,
pointRadiusScale: fd.point_radius_scale,
// Use deck.gl defaults as fallbacks for backward compatibility with existing charts.
// New charts will get control panel defaults (point_radius=10, units='pixels', scale=1).
getPointRadius: toNumber(fd.point_radius, 1),
pointRadiusUnits: fd.point_radius_units ?? 'meters',
pointRadiusScale: toNumber(fd.point_radius_scale, 1),
lineWidthUnits: fd.line_width_unit,
pointType,
...labelOpts,

View File

@@ -22,6 +22,8 @@ import {
legacyValidateInteger,
isFeatureEnabled,
FeatureFlag,
validateNumber,
validateInteger,
} from '@superset-ui/core';
import { formatSelectOptions } from '../../utilities/utils';
import {
@@ -352,15 +354,56 @@ const config: ControlPanelConfig = {
},
],
[
{
name: 'point_radius',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Point Radius'),
description: t(
'The radius of point features, in the units specified below. ' +
'The final rendered size is this value multiplied by Point Radius Scale.',
),
validators: [validateInteger],
default: 10,
choices: formatSelectOptions([1, 5, 10, 20, 50, 100]),
renderTrigger: true,
},
},
{
name: 'point_radius_scale',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Point Radius Scale'),
validators: [legacyValidateInteger],
default: null,
choices: formatSelectOptions([0, 100, 200, 300, 500]),
description: t(
'A multiplier applied to the point radius. ' +
'Use this to uniformly scale all points.',
),
validators: [validateNumber],
default: 1,
choices: formatSelectOptions([0.1, 0.5, 1, 2, 5, 10]),
renderTrigger: true,
},
},
],
[
{
name: 'point_radius_units',
config: {
type: 'SelectControl',
label: t('Point Radius Units'),
description: t(
'The unit for point radius. Use "pixels" for consistent ' +
'screen-space sizing regardless of zoom level.',
),
default: 'pixels',
choices: [
['pixels', t('Pixels')],
['meters', t('Meters')],
['common', t('Common (unit per pixel at zoom 0)')],
],
renderTrigger: true,
},
},
],

View File

@@ -0,0 +1,287 @@
/**
* 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 { act, render, screen } from 'spec/helpers/testing-library';
import { chat } from 'src/core/chat';
import ChatMount from '.';
const disposables: Array<{ dispose: () => void }> = [];
afterEach(() => {
act(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
// Reset host-owned state shared across tests in this module.
chat.close();
chat.setDisplayMode('floating');
});
});
test('renders nothing when no chat extension is registered', () => {
render(<ChatMount />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('renders the trigger bubble of the registered chat', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
// The panel stays unmounted until the chat is opened.
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('mounts the panel when the chat opens and unmounts it on close', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
act(() => chat.open());
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
// In floating mode the trigger stays mounted alongside the open panel.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
act(() => chat.close());
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('renders the last-registered chat when several are installed', () => {
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
),
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
render(<ChatMount />);
// Last-loaded wins: the second registration takes over the singleton slot.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
});
test('reacts to a chat registering after the initial render', () => {
render(<ChatMount />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
});
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('a takeover mounts the incoming chat closed', () => {
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
),
);
render(<ChatMount />);
act(() => chat.open());
expect(screen.getByText('First Panel')).toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
});
// The displaced chat's open state must not leak into the winner.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
});
test('panel mode docks the open panel and hides the trigger', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
act(() => {
chat.setDisplayMode('panel');
chat.open();
});
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
expect(screen.queryByText('Acme Bubble')).not.toBeInTheDocument();
act(() => chat.close());
// A closed chat in panel mode renders nothing — the trigger is hidden too.
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('a crashing panel does not take the trigger down with it', () => {
const FailingPanel = () => {
throw new Error('panel blew up');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <FailingPanel />,
),
);
render(<ChatMount />);
act(() => chat.open());
// The panel's boundary contains the crash; the trigger keeps rendering so
// the user is not stranded without a way back.
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('isolates a failing trigger so it does not crash the host', () => {
const FailingTrigger = () => {
throw new Error('chat blew up');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
),
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatMount />)).not.toThrow();
// The mount slot still renders (the boundary lives inside it), confirming
// the provider was actually exercised and contained.
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('isolates a chat whose provider function itself throws', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => {
throw new Error('provider blew up');
},
() => <div>Acme Panel</div>,
),
);
// ChatRenderer wraps provider() in a component so ErrorBoundary catches
// synchronous throws from the provider function, not just from its output.
expect(() => render(<ChatMount />)).not.toThrow();
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('recovers from a crashed chat when a different chat takes over', () => {
const FailingTrigger = () => {
throw new Error('first chat blew up');
};
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <FailingTrigger />,
() => <div>First Panel</div>,
),
);
render(<ChatMount />);
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
});
// The boundary is keyed per registration, so the latched crash from the
// first chat does not blank the second one.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
});
test('recovers when a crashed chat re-registers a fixed version under the same id', () => {
const FailingTrigger = () => {
throw new Error('broken release');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
expect(screen.queryByText('Fixed Bubble')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <div>Fixed Bubble</div>,
() => <div>Acme Panel</div>,
),
);
});
// Same id, new registrationId: the remounted boundary renders the fix.
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
});

View File

@@ -0,0 +1,149 @@
/**
* 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 { type ReactElement, useRef, useSyncExternalStore } from 'react';
import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme } from '@apache-superset/core/theme';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { store } from 'src/views/store';
import { getChatSnapshot, subscribeToChatState } from 'src/core/chat';
const CHAT_EDGE_MARGIN = 24;
const PANEL_MODE_WIDTH = 400;
/**
* Wraps a chat provider in a React component so that ErrorBoundary can catch
* synchronous throws from the provider function itself. Calling `provider()`
* inline (e.g. `{activeChat.panel()}`) would throw outside React's render
* boundary and crash the host.
*/
const ChatRenderer = ({ provider }: { provider: () => ReactElement }) =>
provider();
const ChatMount = () => {
const theme = useTheme();
// Notify at most once per registration; a crash can re-render and would
// otherwise re-toast, while a replacement (new registrationId) deserves a
// fresh notification if it crashes too.
const crashNotifiedFor = useRef<number | null>(null);
// The active chat, the open state, and the display mode are read from one
// immutable registry snapshot so a render never mixes state from two
// different store versions (the tearing useSyncExternalStore prevents).
const {
open: panelOpen,
mode,
active,
} = useSyncExternalStore(subscribeToChatState, getChatSnapshot);
if (!active) {
return null;
}
const { registrationId } = active;
const onProviderError = (error: Error) => {
// Fault isolation: contain the crash, log it, surface a one-time
// notification, and leave the slot empty rather than parking a
// persistent error card.
logging.error('[chat] provider crashed', error);
if (crashNotifiedFor.current !== registrationId) {
crashNotifiedFor.current = registrationId;
store.dispatch(addDangerToast(t('The chat failed to load.')));
}
};
if (mode === 'panel') {
// Panel mode hides the trigger and docks the panel to the right edge.
// Interim approximation of the "layout slot between header and footer"
// from the chat API contract — the dock overlays the page until the host
// grows a real layout slot and resizer chrome.
if (!panelOpen) {
return null;
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: ${PANEL_MODE_WIDTH}px;
background: ${theme.colorBgContainer};
box-shadow: ${theme.boxShadow};
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
<ErrorBoundary
key={registrationId}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.panel} />
</ErrorBoundary>
</div>
);
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
right: ${CHAT_EDGE_MARGIN}px;
bottom: ${CHAT_EDGE_MARGIN}px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: ${theme.sizeUnit * 2}px;
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
{/*
Each provider gets its own boundary so a crashing panel cannot take
the trigger down with it (the trigger is the user's only way back).
Keyed by registrationId: Superset's ErrorBoundary latches its error
state, so a takeover, fallback, or same-id re-registration must
remount the boundary to recover.
*/}
{panelOpen && (
<ErrorBoundary
key={`panel-${registrationId}`}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.panel} />
</ErrorBoundary>
)}
<ErrorBoundary
key={`trigger-${registrationId}`}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.trigger} />
</ErrorBoundary>
</div>
);
};
export default ChatMount;

View File

@@ -295,6 +295,7 @@ export interface ListViewProps<T extends object = any> {
name: ReactNode;
onSelect: (rows: any[]) => any;
type?: 'primary' | 'secondary' | 'danger';
hidden?: (rows: any[]) => boolean;
}>;
bulkSelectEnabled?: boolean;
disableBulkSelect?: () => void;
@@ -509,7 +510,16 @@ export function ListView<T extends object = any>({
{t('Deselect all')}
</span>
<div className="divider" />
{bulkActions.map(action => (
{bulkActions
.filter(
action =>
!action.hidden?.(
selectedFlatRows.map(
(r: any) => r.original,
),
),
)
.map(action => (
<Button
data-test="bulk-select-action"
data-test-action-key={action.key}

View File

@@ -0,0 +1,327 @@
/**
* 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 { createElement } from 'react';
import { chat, getActiveChat, getChatSnapshot } from './index';
const disposables: Array<{ dispose: () => void }> = [];
const trigger = () => createElement('button', null, 'Bubble');
const panel = () => createElement('div', null, 'Panel');
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
// Reset host-owned state shared across tests in this module.
chat.close();
chat.setDisplayMode('floating');
});
test('getChat returns undefined when no chat is registered', () => {
expect(chat.getChat()).toBeUndefined();
expect(getActiveChat()).toBeUndefined();
});
test('registerChat resolves the registered chat with its providers', () => {
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
disposables.push(chat.registerChat(descriptor, trigger, panel));
expect(chat.getChat()).toEqual(descriptor);
expect(getActiveChat()).toMatchObject({ chat: descriptor, trigger, panel });
});
test('getChat returns a copy that cannot mutate the registry', () => {
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme Chat' }, trigger, panel),
);
const copy = chat.getChat();
copy!.name = 'Hijacked';
expect(chat.getChat()?.name).toBe('Acme Chat');
});
test('the last-registered chat wins when multiple are installed', () => {
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
expect(chat.getChat()?.id).toBe('second.chat');
});
test('disposing the active chat falls back to the previous registration', () => {
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
);
const second = chat.registerChat(
{ id: 'second.chat', name: 'Second' },
trigger,
panel,
);
expect(chat.getChat()?.id).toBe('second.chat');
second.dispose();
expect(chat.getChat()?.id).toBe('first.chat');
});
test('re-registering an id replaces the previous registration', () => {
const stalePanel = () => createElement('div', null, 'Stale');
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, stalePanel),
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
expect(chat.getChat()?.name).toBe('Acme v2');
expect(getActiveChat()?.panel).toBe(panel);
});
test('each registration gets a distinct registrationId, including same-id replacements', () => {
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
const first = getActiveChat()?.registrationId;
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
const second = getActiveChat()?.registrationId;
expect(first).toBeDefined();
expect(second).toBeDefined();
expect(second).not.toBe(first);
});
test('disposing a registration twice unregisters only once', () => {
const unregistered = jest.fn();
disposables.push(chat.onDidUnregisterChat(unregistered));
const registration = chat.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
registration.dispose();
registration.dispose();
expect(unregistered).toHaveBeenCalledTimes(1);
expect(chat.getChat()).toBeUndefined();
});
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
const registered = jest.fn();
const unregistered = jest.fn();
disposables.push(
chat.onDidRegisterChat(registered),
chat.onDidUnregisterChat(unregistered),
);
const descriptor = { id: 'acme.chat', name: 'Acme' };
const registration = chat.registerChat(descriptor, trigger, panel);
expect(registered).toHaveBeenCalledWith(descriptor);
expect(unregistered).not.toHaveBeenCalled();
registration.dispose();
expect(unregistered).toHaveBeenCalledWith(descriptor);
});
test('a disposed event subscription stops receiving notifications', () => {
const registered = jest.fn();
const subscription = chat.onDidRegisterChat(registered);
subscription.dispose();
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
expect(registered).not.toHaveBeenCalled();
});
test('open and close toggle the panel and fire once', () => {
const opened = jest.fn();
const closed = jest.fn();
disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed));
const descriptor = { id: 'acme.chat', name: 'Acme' };
disposables.push(chat.registerChat(descriptor, trigger, panel));
expect(chat.isOpen()).toBe(false);
chat.open();
// Opening an already-open panel is a no-op and must not re-fire.
chat.open();
expect(chat.isOpen()).toBe(true);
expect(opened).toHaveBeenCalledTimes(1);
chat.close();
chat.close();
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('open is a no-op while no chat is registered', () => {
const opened = jest.fn();
disposables.push(chat.onDidOpen(opened));
chat.open();
expect(chat.isOpen()).toBe(false);
expect(opened).not.toHaveBeenCalled();
// A registration arriving later therefore starts closed.
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
expect(chat.isOpen()).toBe(false);
});
test('a takeover by a different id closes the displaced chat panel', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
const first = { id: 'first.chat', name: 'First' };
disposables.push(chat.registerChat(first, trigger, panel));
chat.open();
disposables.push(
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
// The incoming chat must not mount into an open state it never requested.
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('a same-id replacement keeps the open state', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
chat.open();
// Upgrade in place: same id, new providers.
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
expect(chat.isOpen()).toBe(true);
expect(closed).not.toHaveBeenCalled();
});
test('disposing the active chat while open closes it; the fallback starts closed', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
);
const second = { id: 'second.chat', name: 'Second' };
const registration = chat.registerChat(second, trigger, panel);
chat.open();
registration.dispose();
expect(chat.getChat()?.id).toBe('first.chat');
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('disposing an inactive registration leaves the open state untouched', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
const inactive = chat.registerChat(
{ id: 'first.chat', name: 'First' },
trigger,
panel,
);
disposables.push(
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
chat.open();
inactive.dispose();
expect(chat.isOpen()).toBe(true);
expect(closed).not.toHaveBeenCalled();
});
test('disposing the last chat while open resets the open state', () => {
const registration = chat.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
chat.open();
expect(chat.isOpen()).toBe(true);
registration.dispose();
expect(chat.isOpen()).toBe(false);
// A registration arriving much later must not inherit a stale open state.
disposables.push(
chat.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel),
);
expect(chat.isOpen()).toBe(false);
});
test('mode defaults to floating and setDisplayMode fires only on change', () => {
const modeChanged = jest.fn();
disposables.push(chat.onDidChangeDisplayMode(modeChanged));
expect(chat.getDisplayMode()).toBe('floating');
// Setting the current mode is a no-op.
chat.setDisplayMode('floating');
expect(modeChanged).not.toHaveBeenCalled();
chat.setDisplayMode('panel');
expect(chat.getDisplayMode()).toBe('panel');
expect(modeChanged).toHaveBeenCalledWith('panel');
});
test('the snapshot is immutable per version and consistent with the registry', () => {
const before = getChatSnapshot();
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
chat.open();
const after = getChatSnapshot();
// Unchanged references for old snapshots; a new object per change.
expect(after).not.toBe(before);
expect(before.active).toBeUndefined();
expect(after).toMatchObject({
open: true,
mode: 'floating',
active: getActiveChat(),
});
expect(after.version).toBeGreaterThan(before.version);
// Stable reference between changes.
expect(getChatSnapshot()).toBe(after);
});

View File

@@ -0,0 +1,240 @@
/**
* 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.
*/
/**
* @fileoverview Host implementation of the `chat` contribution type.
*
* Chat is a dedicated contribution type, not a view: extensions register via
* the public `chat.registerChat()` and the host owns mounting, open/close
* state, and the display mode. Multiple chat extensions may register, but the
* host applies singleton resolution — the most-recently-registered chat is
* active; disposing it falls back to the previous one.
*
* Open-state policy across active-chat transitions: when the active chat's
* identity changes — a takeover by a different id, disposal falling back to a
* different id, or disposal of the last chat — the panel is closed (firing
* `onDidClose`) so the incoming chat never mounts into an open state it did
* not request. A same-id re-registration is an upgrade in place and keeps the
* open state.
*
* The public namespace (`chat`) is exposed to extensions on
* `window.superset`; the other exports are host-internal accessors for
* ChatMount and are NOT part of the public `@apache-superset/core` API.
*/
import { ReactElement } from 'react';
import type { chat as chatApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createValueEventEmitter, createEventEmitter } from '../utils';
type Chat = chatApi.Chat;
type DisplayMode = chatApi.DisplayMode;
/** A registered chat: its descriptor plus the host-mountable providers. */
export interface RegisteredChat {
/** The chat descriptor passed to `registerChat`. */
chat: Chat;
/** Renders the collapsed bubble. Hidden by the host in panel mode. */
trigger: () => ReactElement;
/** Renders the chat panel, mounted per the current {@link DisplayMode}. */
panel: () => ReactElement;
/**
* Unique per registration (a same-id re-registration gets a new one). The
* host UI keys mounts and fault containment on it, so a replacement resets
* crashed error boundaries instead of inheriting their latched state.
*/
registrationId: number;
}
/**
* Immutable snapshot of the whole chat state, rebuilt on every change.
* Returned by reference from `getChatSnapshot` so `useSyncExternalStore`
* consumers read registrations, open state, and mode from one consistent
* object instead of tearing across separate live reads.
*/
export interface ChatSnapshot {
/** Monotonic change counter, useful as a memo/effect dependency. */
version: number;
/** Whether the active chat's panel is open. */
open: boolean;
/** The current display mode. */
mode: DisplayMode;
/** The active registration, or undefined when none is registered. */
active: RegisteredChat | undefined;
}
/** Registration order is the singleton-resolution order: last entry wins. */
const registrations: RegisteredChat[] = [];
let panelOpen = false;
let nextRegistrationId = 1;
const registerEmitter = createEventEmitter<Chat>();
const unregisterEmitter = createEventEmitter<Chat>();
const openEmitter = createEventEmitter<void>();
const closeEmitter = createEventEmitter<void>();
const resizePanelEmitter = createEventEmitter<{ width: number }>();
const modeEmitter = createValueEventEmitter<DisplayMode>('floating');
/**
* Host-internal: resolves the active chat with its providers.
* The most-recently-registered chat wins; when it is disposed the previous
* registration takes over the slot again.
*/
export const getActiveChat = (): RegisteredChat | undefined =>
registrations[registrations.length - 1];
let snapshot: ChatSnapshot = {
version: 0,
open: false,
mode: modeEmitter.getCurrent(),
active: undefined,
};
const stateSubscribers = new Set<() => void>();
const notifyState = () => {
snapshot = {
version: snapshot.version + 1,
open: panelOpen,
mode: modeEmitter.getCurrent(),
active: getActiveChat(),
};
stateSubscribers.forEach(fn => fn());
};
export const subscribeToChatState = (listener: () => void): (() => void) => {
stateSubscribers.add(listener);
return () => {
stateSubscribers.delete(listener);
};
};
export const getChatSnapshot = (): ChatSnapshot => snapshot;
/** Closes the panel and fires `onDidClose`. */
const closePanel = () => {
panelOpen = false;
closeEmitter.fire();
};
const registerChat: typeof chatApi.registerChat = (
chat: Chat,
trigger: () => ReactElement,
panel: () => ReactElement,
): Disposable => {
const previousActive = getActiveChat();
// Re-registering an id replaces the previous entry and moves it to the
// most-recent position, mirroring the view registry's same-id semantics.
const existingIndex = registrations.findIndex(r => r.chat.id === chat.id);
if (existingIndex !== -1) {
registrations.splice(existingIndex, 1);
}
const entry: RegisteredChat = {
chat,
trigger,
panel,
registrationId: nextRegistrationId,
};
nextRegistrationId += 1;
registrations.push(entry);
registerEmitter.fire(chat);
// A takeover by a different id closes the displaced chat's panel so the
// incoming chat never mounts already-open; a same-id replacement is an
// upgrade in place and keeps the open state.
if (panelOpen && previousActive && previousActive.chat.id !== chat.id) {
closePanel();
}
notifyState();
return new Disposable(() => {
const index = registrations.indexOf(entry);
if (index === -1) {
// Already removed — replaced by a same-id registration or disposed twice.
return;
}
const wasActive = getActiveChat() === entry;
registrations.splice(index, 1);
unregisterEmitter.fire(chat);
// Disposing the active chat closes its panel; the fallback chat (if any)
// starts closed. Disposing an inactive registration leaves the open
// state of the active chat untouched.
if (panelOpen && wasActive) {
closePanel();
}
notifyState();
});
};
const getChat: typeof chatApi.getChat = (): Chat | undefined => {
const active = getActiveChat();
// Copy so extensions cannot mutate another extension's descriptor.
return active ? { ...active.chat } : undefined;
};
const open: typeof chatApi.open = (): void => {
const active = getActiveChat();
// Open state only exists while a chat is registered; opening an empty slot
// would otherwise leak `open` into a future, unrelated registration.
if (panelOpen || !active) return;
panelOpen = true;
openEmitter.fire();
notifyState();
};
const close: typeof chatApi.close = (): void => {
const active = getActiveChat();
if (!panelOpen || !active) return;
closePanel();
notifyState();
};
const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen;
const getDisplayMode: typeof chatApi.getDisplayMode = (): DisplayMode =>
modeEmitter.getCurrent();
const setDisplayMode: typeof chatApi.setDisplayMode = (
displayMode: DisplayMode,
): void => {
if (displayMode === modeEmitter.getCurrent()) return;
modeEmitter.fire(displayMode);
notifyState();
};
export const chat: typeof chatApi = {
registerChat,
getChat,
onDidRegisterChat: registerEmitter.subscribe,
onDidUnregisterChat: unregisterEmitter.subscribe,
open,
close,
isOpen,
onDidOpen: openEmitter.subscribe,
onDidClose: closeEmitter.subscribe,
getDisplayMode,
setDisplayMode,
onDidChangeDisplayMode: modeEmitter.subscribe,
// The host fires this from its panel resizer; until that chrome exists the
// event is exposed but never fires.
onDidResizePanel: resizePanelEmitter.subscribe,
};

View File

@@ -254,33 +254,6 @@ test('event listeners can be disposed', () => {
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
});
test('handles errors in event listeners gracefully', () => {
const manager = EditorProviders.getInstance();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const errorListener = jest.fn(() => {
throw new Error('Listener error');
});
const successListener = jest.fn();
manager.onDidRegister(errorListener);
manager.onDidRegister(successListener);
manager.registerProvider(createMockEditor(), createMockEditorComponent());
// Both listeners should have been called
expect(errorListener).toHaveBeenCalledTimes(1);
expect(successListener).toHaveBeenCalledTimes(1);
// Error should have been logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error in event listener:',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
test('reset clears all providers and language mappings', () => {
const manager = EditorProviders.getInstance();

View File

@@ -19,6 +19,7 @@
import type { editors } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type EditorLanguage = editors.EditorLanguage;
type EditorProvider = editors.EditorProvider;
@@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent;
type EditorRegisteredEvent = editors.EditorRegisteredEvent;
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
/**
* Listener function type for events.
*/
type Listener<T> = (e: T) => void;
/**
* Simple event emitter for editor provider lifecycle events.
*/
class EventEmitter<T> {
private listeners: Set<Listener<T>> = new Set();
/**
* Subscribe to this event.
* @param listener The listener function to call when the event is fired.
* @returns A Disposable to unsubscribe from the event.
*/
subscribe(listener: Listener<T>): Disposable {
this.listeners.add(listener);
return new Disposable(() => {
this.listeners.delete(listener);
});
}
/**
* Fire the event with the given data.
* @param data The event data to pass to listeners.
*/
fire(data: T): void {
this.listeners.forEach(listener => {
try {
listener(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error in event listener:', error);
}
});
}
}
/**
* Singleton manager for editor providers.
* Handles registration, resolution, and lifecycle of custom editor implementations.
@@ -83,15 +47,9 @@ class EditorProviders {
*/
private languageToProvider: Map<EditorLanguage, string> = new Map();
/**
* Event emitter for provider registration events.
*/
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
/**
* Event emitter for provider unregistration events.
*/
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
private syncListeners: Set<() => void> = new Set();

View File

@@ -27,11 +27,13 @@ export const core: typeof coreType = {
};
export * from './authentication';
export * from './chat';
export * from './commands';
export * from './editors';
export * from './extensions';
export * from './menus';
export * from './models';
export * from './navigation';
export * from './sqlLab';
export * from './utils';
export * from './views';

View File

@@ -27,6 +27,7 @@
import { useSyncExternalStore } from 'react';
import type { menus as menusApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type MenuItem = menusApi.MenuItem;
type Menu = menusApi.Menu;
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
const menuCache = new Map<string, Menu | undefined>();
const notifyRegister = (event: MenuItemRegisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
registerListeners.forEach(l => l(event));
registerEmitter.fire(event);
};
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
unregisterListeners.forEach(l => l(event));
unregisterEmitter.fire(event);
};
const registerMenuItem: typeof menusApi.registerMenuItem = (
@@ -117,16 +118,11 @@ export const useMenu = (location: string): Menu | undefined =>
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
listener: (e: MenuItemRegisteredEvent) => void,
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
): Disposable => registerEmitter.subscribe(listener);
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable =>
unregisterEmitter.subscribe(listener);
export const menus: typeof menusApi = {
registerMenuItem,

View File

@@ -0,0 +1,124 @@
/**
* 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.
*/
// Reset module state between tests so currentPage is re-initialized.
beforeEach(() => {
jest.resetModules();
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/' },
});
});
async function importNavigation() {
const mod = await import('./index');
return mod;
}
test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
const { navigation, notifyPageChange } = await importNavigation();
// The default pathname ('/') is not enumerated and falls back to home.
expect(navigation.getPage()).toBe('home');
notifyPageChange('/superset/welcome/');
expect(navigation.getPage()).toBe('home');
});
test('getPage derives the page from window.location.pathname', async () => {
window.location.pathname = '/superset/dashboard/42/';
const { navigation } = await importNavigation();
expect(navigation.getPage()).toBe('dashboard');
});
test('notifyPageChange updates the current page type', async () => {
const { navigation, notifyPageChange } = await importNavigation();
notifyPageChange('/explore/?form_data={}');
expect(navigation.getPage()).toBe('explore');
});
test('notifyPageChange fires listeners on page type change', async () => {
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
notifyPageChange('/superset/dashboard/1/');
expect(listener).toHaveBeenCalledWith('dashboard');
disposable.dispose();
});
test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
window.location.pathname = '/superset/dashboard/1/';
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
navigation.onDidChangePage(listener);
notifyPageChange('/superset/dashboard/2/');
expect(listener).not.toHaveBeenCalled();
});
test('onDidChangePage listener is removed after dispose', async () => {
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
disposable.dispose();
notifyPageChange('/superset/dashboard/1/');
expect(listener).not.toHaveBeenCalled();
});
test('sqllab path is matched with and without trailing slash', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/sqllab');
expect(navigation.getPage()).toBe('sqllab');
notifyPageChange('/explore/');
notifyPageChange('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
});
test('chart and dashboard list pages get their own page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/chart/list/');
expect(navigation.getPage()).toBe('chart_list');
notifyPageChange('/dashboard/list/');
expect(navigation.getPage()).toBe('dashboard_list');
});
test('dataset list and single-dataset pages get distinct page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/tablemodelview/list/');
expect(navigation.getPage()).toBe('dataset_list');
notifyPageChange('/dataset/42');
expect(navigation.getPage()).toBe('dataset');
});
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
notifyPageChange('/sqllab/history/');
expect(navigation.getPage()).toBe('query_history');
notifyPageChange('/savedqueryview/list/');
expect(navigation.getPage()).toBe('saved_queries');
});
test('chart/add resolves to explore, not chart_list', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/chart/add');
expect(navigation.getPage()).toBe('explore');
});

View File

@@ -0,0 +1,79 @@
/**
* 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.
*/
/**
* Host-internal implementation of the `navigation` namespace.
*
* Backed by browser location — no Redux dependency.
* The app shell calls `notifyPageChange(pathname)` whenever the route changes.
*/
import type { navigation as navigationApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type Page = navigationApi.Page;
const pageChangeEmitter = createEventEmitter<Page>();
function derivePage(pathname: string): Page {
if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
if (pathname.startsWith('/explore/')) return 'explore';
if (pathname.startsWith('/superset/explore/')) return 'explore';
if (pathname.startsWith('/chart/add')) return 'explore';
if (pathname.startsWith('/chart/list')) return 'chart_list';
if (pathname.startsWith('/sqllab/history')) return 'query_history';
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
if (pathname === '/sqllab' || pathname.startsWith('/sqllab/'))
return 'sqllab';
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
if (pathname.startsWith('/dataset/')) return 'dataset';
// The welcome page and any route not explicitly enumerated fall back to home.
return 'home';
}
let currentPage: Page | undefined;
function getOrInitPage(): Page {
if (currentPage === undefined) {
currentPage = derivePage(window.location.pathname);
}
return currentPage;
}
/** Called by ExtensionsStartup whenever the React Router location changes. */
export const notifyPageChange = (pathname: string): void => {
const next = derivePage(pathname);
if (next === getOrInitPage()) return;
currentPage = next;
pageChangeEmitter.fire(next);
};
const getPage: typeof navigationApi.getPage = () => getOrInitPage();
const onDidChangePage: typeof navigationApi.onDidChangePage = (
listener: (page: Page) => void,
thisArgs?: any,
): Disposable => pageChangeEmitter.subscribe(listener, thisArgs);
export const navigation: typeof navigationApi = {
getPage,
onDidChangePage,
};

View File

@@ -21,6 +21,57 @@ import { AnyAction } from 'redux';
import { listenerMiddleware, RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
type Listener<T> = (e: T) => unknown;
/** A stateless event emitter exposing a VS Code-style `event` subscriber. */
export interface EventEmitter<T> {
/** Notifies every current subscriber with `value`. */
fire(value: T): void;
/** Registers a listener; returns a Disposable that removes it. */
subscribe: core.Event<T>;
}
/** An event emitter that also retains the last fired value. */
export interface ValueEventEmitter<T> extends EventEmitter<T> {
/** Returns the value last passed to {@link fire} (or the initial value). */
getCurrent(): T;
}
/**
* Creates a stateless event emitter. Listeners registered via `event` receive
* every subsequent `fire`; a returned Disposable removes the listener.
*/
export function createEventEmitter<T>(): EventEmitter<T> {
const listeners = new Set<Listener<T>>();
const subscribe: core.Event<T> = (listener, thisArgs) => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
listeners.add(bound);
return { dispose: () => listeners.delete(bound) };
};
return {
fire: value => listeners.forEach(fn => fn(value)),
subscribe,
};
}
/**
* Creates a value event emitter seeded with `initial`. Behaves like
* {@link createEventEmitter} but also tracks the last fired value, readable
* via `getCurrent` — useful for state that is both observed and queried.
*/
export function createValueEventEmitter<T>(initial: T): ValueEventEmitter<T> {
const { fire, subscribe } = createEventEmitter<T>();
let current = initial;
return {
fire: value => {
current = value;
fire(value);
},
subscribe,
getCurrent: () => current,
};
}
export function createActionListener<V>(
predicate: AnyListenerPredicate<RootState>,
listener: (v: V) => void,

View File

@@ -29,6 +29,7 @@ import type { views as viewsApi } from '@apache-superset/core';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type View = viewsApi.View;
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
const registerEmitter = createEventEmitter<ViewRegisteredEvent>();
const unregisterEmitter = createEventEmitter<ViewUnregisteredEvent>();
const viewsCache = new Map<string, View[] | undefined>();
const notifyRegister = (event: ViewRegisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
registerListeners.forEach(l => l(event));
registerEmitter.fire(event);
};
const notifyUnregister = (event: ViewUnregisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
unregisterListeners.forEach(l => l(event));
unregisterEmitter.fire(event);
};
const registerView: typeof viewsApi.registerView = (
@@ -116,17 +117,11 @@ export const useViews = (location: string): View[] | undefined =>
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
listener: (e: ViewRegisteredEvent) => void,
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
): Disposable => registerEmitter.subscribe(listener);
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
listener: (e: ViewUnregisteredEvent) => void,
): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
): Disposable => unregisterEmitter.subscribe(listener);
export const views: typeof viewsApi = {
registerView,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, ReactNode } from 'react';
import { ReactNode, useCallback, useContext, useEffect, useRef } from 'react';
import { t } from '@apache-superset/core/translation';
import { JsonObject } from '@superset-ui/core';
@@ -90,165 +90,61 @@ interface VisibilityEventData {
ts: number;
}
class Dashboard extends PureComponent<DashboardProps> {
static contextType = PluginContext;
function unload(event: BeforeUnloadEvent): string {
const message = t('You have unsaved changes.');
// Set returnValue on the actual event object to trigger the browser prompt
event.returnValue = message;
return message; // Gecko + Webkit, Safari, Chrome etc.
}
// Use type assertion when accessing context instead of declare field
// to avoid babel transformation issues in Jest
static defaultProps = {
timeout: 60,
userId: '',
};
appliedFilters: ActiveFilters;
appliedOwnDataCharts: JsonObject;
visibilityEventData: VisibilityEventData;
static onBeforeUnload(hasChanged: boolean): void {
if (hasChanged) {
window.addEventListener('beforeunload', Dashboard.unload);
} else {
window.removeEventListener('beforeunload', Dashboard.unload);
}
function onBeforeUnload(hasChanged: boolean): void {
if (hasChanged) {
window.addEventListener('beforeunload', unload);
} else {
window.removeEventListener('beforeunload', unload);
}
}
static unload(): string {
const message = t('You have unsaved changes.');
// Gecko + IE: returnValue is typed as boolean but historically accepts string
(window.event as BeforeUnloadEvent).returnValue = message;
return message; // Gecko + Webkit, Safari, Chrome etc.
}
function Dashboard({
actions,
dashboardId,
editMode,
isPublished,
hasUnsavedChanges,
slices,
activeFilters,
chartConfiguration,
datasources,
ownDataCharts,
layout,
impressionId,
timeout = 60,
userId = '',
children,
}: DashboardProps): JSX.Element {
const context = useContext(PluginContext) as PluginContextType;
constructor(props: DashboardProps) {
super(props);
this.appliedFilters = props.activeFilters ?? {};
this.appliedOwnDataCharts = props.ownDataCharts ?? {};
this.visibilityEventData = { start_offset: 0, ts: 0 };
this.onVisibilityChange = this.onVisibilityChange.bind(this);
}
// Use refs to track mutable values that persist across renders
const appliedFiltersRef = useRef<ActiveFilters>(activeFilters ?? {});
const appliedOwnDataChartsRef = useRef<JsonObject>(ownDataCharts ?? {});
const visibilityEventDataRef = useRef<VisibilityEventData>({
start_offset: 0,
ts: 0,
});
const prevLayoutRef = useRef<DashboardLayout>(layout);
const prevDashboardIdRef = useRef<number>(dashboardId);
componentDidMount(): void {
const bootstrapData = getBootstrapData();
const { editMode, isPublished, layout } = this.props;
const eventData: Record<string, unknown> = {
is_soft_navigation: Logger.timeOriginOffset > 0,
is_edit_mode: editMode,
mount_duration: Logger.getTimestamp(),
is_empty: isDashboardEmpty(layout),
is_published: isPublished,
bootstrap_data_length: JSON.stringify(bootstrapData).length,
};
const directLinkComponentId = getLocationHash();
if (directLinkComponentId) {
eventData.target_id = directLinkComponentId;
}
this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
// Handle browser tab visibility change
if (document.visibilityState === 'hidden') {
this.visibilityEventData = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
}
window.addEventListener('visibilitychange', this.onVisibilityChange);
this.applyCharts();
}
componentDidUpdate(prevProps: DashboardProps): void {
this.applyCharts();
const currentChartIds = getChartIdsFromLayout(prevProps.layout);
const nextChartIds = getChartIdsFromLayout(this.props.layout);
if (prevProps.dashboardId !== this.props.dashboardId) {
// single-page-app navigation check
return;
}
if (currentChartIds.length < nextChartIds.length) {
const newChartIds = nextChartIds.filter(
key => currentChartIds.indexOf(key) === -1,
);
newChartIds.forEach(newChartId =>
this.props.actions.addSliceToDashboard(
newChartId,
getLayoutComponentFromChartId(this.props.layout, newChartId),
),
);
} else if (currentChartIds.length > nextChartIds.length) {
// remove chart
const removedChartIds = currentChartIds.filter(
key => nextChartIds.indexOf(key) === -1,
);
removedChartIds.forEach(removedChartId =>
this.props.actions.removeSliceFromDashboard(removedChartId),
);
}
}
applyCharts(): void {
const {
activeFilters,
ownDataCharts,
chartConfiguration,
hasUnsavedChanges,
editMode,
} = this.props;
const { appliedFilters, appliedOwnDataCharts } = this;
if (!chartConfiguration) {
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
// for correct comparing of filters to avoid unnecessary requests
return;
}
if (
!editMode &&
(!areObjectsEqual(appliedOwnDataCharts, ownDataCharts, {
ignoreUndefined: true,
}) ||
!areObjectsEqual(appliedFilters, activeFilters, {
ignoreUndefined: true,
}))
) {
this.applyFilters();
}
if (hasUnsavedChanges) {
Dashboard.onBeforeUnload(true);
} else {
Dashboard.onBeforeUnload(false);
}
}
componentWillUnmount(): void {
window.removeEventListener('visibilitychange', this.onVisibilityChange);
this.props.actions.clearDataMaskState();
this.props.actions.clearAllChartStates();
}
onVisibilityChange(): void {
if (document.visibilityState === 'hidden') {
// from visible to hidden
this.visibilityEventData = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
} else if (document.visibilityState === 'visible') {
// from hidden to visible
const logStart = this.visibilityEventData.start_offset;
this.props.actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
...this.visibilityEventData,
duration: Logger.getTimestamp() - logStart,
const refreshCharts = useCallback(
(ids: (string | number)[]): void => {
ids.forEach(id => {
actions.triggerQuery(true, id);
});
}
}
},
[actions],
);
applyFilters(): void {
const { appliedFilters } = this;
const { activeFilters, ownDataCharts, slices } = this.props;
const applyFilters = useCallback((): void => {
const appliedFilters = appliedFiltersRef.current;
// refresh charts if a filter was removed, added, or changed
@@ -258,7 +154,7 @@ class Dashboard extends PureComponent<DashboardProps> {
const allKeys = new Set(currFilterKeys.concat(appliedFilterKeys));
const affectedChartIds: (string | number)[] = getAffectedOwnDataCharts(
ownDataCharts,
this.appliedOwnDataCharts,
appliedOwnDataChartsRef.current,
);
[...allKeys].forEach(filterKey => {
@@ -321,24 +217,157 @@ class Dashboard extends PureComponent<DashboardProps> {
});
// remove dup in affectedChartIds
this.refreshCharts([...new Set(affectedChartIds)]);
this.appliedFilters = activeFilters;
this.appliedOwnDataCharts = ownDataCharts;
}
refreshCharts([...new Set(affectedChartIds)]);
appliedFiltersRef.current = activeFilters;
appliedOwnDataChartsRef.current = ownDataCharts;
}, [activeFilters, ownDataCharts, slices, refreshCharts]);
refreshCharts(ids: (string | number)[]): void {
ids.forEach(id => {
this.props.actions.triggerQuery(true, id);
});
}
render(): ReactNode {
const context = this.context as PluginContextType;
if (context.loading) {
return <Loading />;
const applyCharts = useCallback((): void => {
if (!chartConfiguration) {
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
// for correct comparing of filters to avoid unnecessary requests
return;
}
return this.props.children;
if (
!editMode &&
(!areObjectsEqual(appliedOwnDataChartsRef.current, ownDataCharts, {
ignoreUndefined: true,
}) ||
!areObjectsEqual(appliedFiltersRef.current, activeFilters, {
ignoreUndefined: true,
}))
) {
applyFilters();
}
if (hasUnsavedChanges) {
onBeforeUnload(true);
} else {
onBeforeUnload(false);
}
}, [
chartConfiguration,
editMode,
ownDataCharts,
activeFilters,
hasUnsavedChanges,
applyFilters,
]);
const onVisibilityChange = useCallback((): void => {
if (document.visibilityState === 'hidden') {
// from visible to hidden
visibilityEventDataRef.current = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
} else if (document.visibilityState === 'visible') {
// from hidden to visible
const logStart = visibilityEventDataRef.current.start_offset;
actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
...visibilityEventDataRef.current,
duration: Logger.getTimestamp() - logStart,
});
}
}, [actions]);
// Refs that always point at the latest closures so the mount-only effect's
// listeners/cleanup never invoke a stale `actions` closure when `actions`
// identity changes.
const onVisibilityChangeRef = useRef(onVisibilityChange);
const actionsRef = useRef(actions);
useEffect(() => {
onVisibilityChangeRef.current = onVisibilityChange;
actionsRef.current = actions;
});
// componentDidMount equivalent
useEffect(() => {
const bootstrapData = getBootstrapData();
const eventData: Record<string, unknown> = {
is_soft_navigation: Logger.timeOriginOffset > 0,
is_edit_mode: editMode,
mount_duration: Logger.getTimestamp(),
is_empty: isDashboardEmpty(layout),
is_published: isPublished,
bootstrap_data_length: JSON.stringify(bootstrapData).length,
};
const directLinkComponentId = getLocationHash();
if (directLinkComponentId) {
eventData.target_id = directLinkComponentId;
}
actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
// Handle browser tab visibility change
if (document.visibilityState === 'hidden') {
visibilityEventDataRef.current = {
start_offset: Logger.getTimestamp(),
ts: new Date().getTime(),
};
}
const handleVisibilityChange = () => onVisibilityChangeRef.current();
document.addEventListener('visibilitychange', handleVisibilityChange);
// componentWillUnmount equivalent
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
onBeforeUnload(false); // Remove beforeunload listener on unmount
actionsRef.current.clearDataMaskState();
actionsRef.current.clearAllChartStates();
};
// Only run on mount/unmount - listeners/cleanup go through refs to avoid
// capturing stale closures.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Apply charts on every render (like componentDidMount + componentDidUpdate calling applyCharts)
useEffect(() => {
applyCharts();
}, [applyCharts]);
// componentDidUpdate equivalent for layout changes
useEffect(() => {
const prevLayout = prevLayoutRef.current;
const prevDashboardId = prevDashboardIdRef.current;
// Update refs for next comparison
prevLayoutRef.current = layout;
prevDashboardIdRef.current = dashboardId;
const currentChartIds = getChartIdsFromLayout(prevLayout);
const nextChartIds = getChartIdsFromLayout(layout);
if (prevDashboardId !== dashboardId) {
// single-page-app navigation check
return;
}
if (currentChartIds.length < nextChartIds.length) {
const newChartIds = nextChartIds.filter(
key => currentChartIds.indexOf(key) === -1,
);
newChartIds.forEach(newChartId =>
actions.addSliceToDashboard(
newChartId,
getLayoutComponentFromChartId(layout, newChartId),
),
);
} else if (currentChartIds.length > nextChartIds.length) {
// remove chart
const removedChartIds = currentChartIds.filter(
key => nextChartIds.indexOf(key) === -1,
);
removedChartIds.forEach(removedChartId =>
actions.removeSliceFromDashboard(removedChartId),
);
}
}, [layout, dashboardId, actions]);
if (context.loading) {
return <Loading />;
}
return <>{children}</>;
}
export default Dashboard;

View File

@@ -31,9 +31,10 @@ export const getRootLevelTabsComponent = (dashboardLayout: DashboardLayout) => {
};
export const shouldFocusTabs = (
event: { target: { className: string } },
container: { contains: (arg0: any) => any },
) =>
event: { target: HTMLElement },
container: Pick<Node, 'contains'> | null,
_menuRef: HTMLDivElement | null,
): boolean =>
// don't focus the tabs when we click on a tab
event.target.className === 'ant-tabs-nav-wrap' ||
container.contains(event.target);
(container?.contains(event.target) ?? false);

View File

@@ -16,12 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, Fragment } from 'react';
import { withTheme } from '@emotion/react';
import { Fragment, useCallback, useRef, useState } from 'react';
import classNames from 'classnames';
import { addAlpha } from '@superset-ui/core';
import { css, styled, type SupersetTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { EmptyState } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { navigateTo } from 'src/utils/navigationUtils';
@@ -48,11 +47,6 @@ export interface DashboardGridProps {
setEditMode?: (editMode: boolean) => void;
width: number;
dashboardId?: number;
theme: SupersetTheme;
}
interface DashboardGridState {
isResizing: boolean;
}
interface DropProps {
@@ -131,261 +125,235 @@ const GridColumnGuide = styled.div`
`};
`;
class DashboardGrid extends PureComponent<
DashboardGridProps,
DashboardGridState
> {
grid: HTMLDivElement | null;
function DashboardGrid({
depth,
editMode,
canEdit,
gridComponent,
handleComponentDrop,
isComponentVisible,
resizeComponent,
setDirectPathToChild,
setEditMode,
width,
dashboardId,
}: DashboardGridProps) {
const theme = useTheme();
const [isResizing, setIsResizing] = useState(false);
const gridRef = useRef<HTMLDivElement | null>(null);
constructor(props: DashboardGridProps) {
super(props);
this.state = {
isResizing: false,
};
this.grid = null;
this.handleResizeStart = this.handleResizeStart.bind(this);
this.handleResize = this.handleResize.bind(this);
this.handleResizeStop = this.handleResizeStop.bind(this);
this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
this.setGridRef = this.setGridRef.bind(this);
this.handleChangeTab = this.handleChangeTab.bind(this);
}
const setGridRef = useCallback((ref: HTMLDivElement | null): void => {
gridRef.current = ref;
}, []);
getRowGuidePosition(resizeRef: HTMLElement | null): number | null {
if (resizeRef && this.grid) {
return (
resizeRef.getBoundingClientRect().bottom -
this.grid.getBoundingClientRect().top -
2
);
}
return null;
}
const handleResizeStart = useCallback((): void => {
setIsResizing(true);
}, []);
setGridRef(ref: HTMLDivElement | null): void {
this.grid = ref;
}
const handleResize = useCallback(
(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
_delta: { width: number; height: number },
): void => {
// no-op: resize position tracking not implemented
},
[],
);
handleResizeStart(): void {
this.setState(() => ({
isResizing: true,
}));
}
handleResize(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
_delta: { width: number; height: number },
): void {
// no-op: resize position is tracked via getRowGuidePosition
}
handleResizeStop(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
): void {
this.props.resizeComponent({
id,
width: delta.width,
height: delta.height,
});
this.setState(() => ({
isResizing: false,
}));
}
handleTopDropTargetDrop(dropResult: DropResult): void {
if (dropResult?.destination) {
this.props.handleComponentDrop({
...dropResult,
destination: {
...dropResult.destination,
// force appending as the first child if top drop target
index: 0,
},
const handleResizeStop = useCallback(
(
_event: MouseEvent | TouchEvent,
_direction: string,
_elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
): void => {
resizeComponent({
id,
width: delta.width,
height: delta.height,
});
}
}
handleChangeTab({ pathToTabIndex }: { pathToTabIndex: string[] }): void {
this.props.setDirectPathToChild(pathToTabIndex);
}
setIsResizing(false);
},
[resizeComponent],
);
render() {
const {
gridComponent,
handleComponentDrop,
depth,
width,
isComponentVisible,
editMode,
canEdit,
setEditMode,
dashboardId,
theme,
} = this.props;
const columnPlusGutterWidth =
(width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
const handleTopDropTargetDrop = useCallback(
(dropResult: DropResult): void => {
if (dropResult?.destination) {
handleComponentDrop({
...dropResult,
destination: {
...dropResult.destination,
// force appending as the first child if top drop target
index: 0,
},
});
}
},
[handleComponentDrop],
);
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
const { isResizing } = this.state;
const handleChangeTab = useCallback(
({ pathToTabIndex }: { pathToTabIndex: string[] }): void => {
setDirectPathToChild(pathToTabIndex);
},
[setDirectPathToChild],
);
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
const shouldDisplayTopLevelTabEmptyState =
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
const dashboardEmptyState = editMode && (
<EmptyState
title={t('Drag and drop components and charts to the dashboard')}
description={t(
'You can create a new chart or use existing ones from the panel on the right',
)}
size="large"
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
);
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
const topLevelTabEmptyState = editMode ? (
<EmptyState
title={t('Drag and drop components to this tab')}
size="large"
description={t(
`You can create a new chart or use existing ones from the panel on the right`,
)}
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
) : (
<EmptyState
title={t('There are no components added to this tab')}
size="large"
description={
canEdit && t('You can add the components in the edit mode.')
}
buttonText={canEdit ? t('Edit the dashboard') : undefined}
buttonAction={
canEdit
? () => {
setEditMode?.(true);
}
: undefined
}
image="chart.svg"
/>
);
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
const shouldDisplayTopLevelTabEmptyState =
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
return width < 100 ? null : (
<>
{shouldDisplayEmptyState && (
<DashboardEmptyStateContainer>
{shouldDisplayTopLevelTabEmptyState
? topLevelTabEmptyState
: dashboardEmptyState}
</DashboardEmptyStateContainer>
)}
<div className="dashboard-grid" ref={this.setGridRef}>
<GridContent
className="grid-content"
data-test="grid-content"
editMode={editMode}
>
{/* make the area above components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={0}
orientation="column"
onDrop={this.handleTopDropTargetDrop}
className={classNames({
'empty-droptarget': true,
'empty-droptarget--full':
gridComponent?.children?.length === 0,
})}
editMode
dropToChild={gridComponent?.children?.length === 0}
>
{renderDraggableContent}
</Droppable>
)}
{gridComponent?.children?.map((id, index) => (
<Fragment key={id}>
<DashboardComponent
id={id}
parentId={gridComponent.id}
depth={depth + 1}
index={index}
availableColumnCount={GRID_COLUMN_COUNT}
columnWidth={columnWidth}
isComponentVisible={isComponentVisible}
onResizeStart={this.handleResizeStart}
onResize={this.handleResize}
onResizeStop={this.handleResizeStop}
onChangeTab={this.handleChangeTab}
const dashboardEmptyState = editMode && (
<EmptyState
title={t('Drag and drop components and charts to the dashboard')}
description={t(
'You can create a new chart or use existing ones from the panel on the right',
)}
size="large"
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
);
const topLevelTabEmptyState = editMode ? (
<EmptyState
title={t('Drag and drop components to this tab')}
size="large"
description={t(
`You can create a new chart or use existing ones from the panel on the right`,
)}
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
) : (
<EmptyState
title={t('There are no components added to this tab')}
size="large"
description={canEdit && t('You can add the components in the edit mode.')}
buttonText={canEdit ? t('Edit the dashboard') : undefined}
buttonAction={
canEdit
? () => {
setEditMode?.(true);
}
: undefined
}
image="chart.svg"
/>
);
return width < 100 ? null : (
<>
{shouldDisplayEmptyState && (
<DashboardEmptyStateContainer>
{shouldDisplayTopLevelTabEmptyState
? topLevelTabEmptyState
: dashboardEmptyState}
</DashboardEmptyStateContainer>
)}
<div className="dashboard-grid" ref={setGridRef}>
<GridContent
className="grid-content"
data-test="grid-content"
editMode={editMode}
>
{/* make the area above components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={0}
orientation="column"
onDrop={handleTopDropTargetDrop}
className={classNames({
'empty-droptarget': true,
'empty-droptarget--full': gridComponent?.children?.length === 0,
})}
editMode
dropToChild={gridComponent?.children?.length === 0}
>
{renderDraggableContent}
</Droppable>
)}
{gridComponent?.children?.map((id, index) => (
<Fragment key={id}>
<DashboardComponent
id={id}
parentId={gridComponent.id}
depth={depth + 1}
index={index}
availableColumnCount={GRID_COLUMN_COUNT}
columnWidth={columnWidth}
isComponentVisible={isComponentVisible}
onResizeStart={handleResizeStart}
onResize={handleResize}
onResizeStop={handleResizeStop}
onChangeTab={handleChangeTab}
/>
{/* make the area below components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={index + 1}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{renderDraggableContent}
</Droppable>
)}
</Fragment>
))}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<GridColumnGuide
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
/>
{/* make the area below components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={index + 1}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{renderDraggableContent}
</Droppable>
)}
</Fragment>
))}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<GridColumnGuide
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
/>
))}
</GridContent>
</div>
</>
);
}
))}
</GridContent>
</div>
</>
);
}
export default withTheme(DashboardGrid);
export default DashboardGrid;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component } from 'react';
import { useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import { Tooltip, PublishedLabel } from '@superset-ui/core/components';
import { HeaderProps, HeaderDropdownProps } from '../Header/types';
@@ -43,70 +43,64 @@ const publishedTooltip = t(
'This dashboard is published. Click to make it a draft.',
);
export default class PublishedStatus extends Component<DashboardPublishedStatusType> {
constructor(props: DashboardPublishedStatusType) {
super(props);
this.togglePublished = this.togglePublished.bind(this);
}
export default function PublishedStatus({
dashboardId,
userCanEdit,
userCanSave,
isPublished,
savePublished,
}: DashboardPublishedStatusType) {
const togglePublished = useCallback(() => {
savePublished(dashboardId, !isPublished);
}, [dashboardId, isPublished, savePublished]);
togglePublished() {
this.props.savePublished(this.props.dashboardId, !this.props.isPublished);
}
render() {
const { isPublished, userCanEdit, userCanSave } = this.props;
// Show everybody the draft badge
if (!isPublished) {
// if they can edit the dash, make the badge a button
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftButtonTooltip}
>
<div>
<PublishedLabel
isPublished={isPublished}
onClick={this.togglePublished}
/>
</div>
</Tooltip>
);
}
// Show everybody the draft badge
if (!isPublished) {
// if they can edit the dash, make the badge a button
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftDivTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} />
</div>
</Tooltip>
);
}
// Show the published badge for the owner of the dashboard to toggle
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="published-dashboard-tooltip"
placement="bottom"
title={publishedTooltip}
title={draftButtonTooltip}
>
<div>
<PublishedLabel
isPublished={isPublished}
onClick={this.togglePublished}
onClick={togglePublished}
/>
</div>
</Tooltip>
);
}
// Don't show anything if one doesn't own the dashboard and it is published
return null;
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftDivTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} />
</div>
</Tooltip>
);
}
// Show the published badge for the owner of the dashboard to toggle
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="published-dashboard-tooltip"
placement="bottom"
title={publishedTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} onClick={togglePublished} />
</div>
</Tooltip>
);
}
// Don't show anything if one doesn't own the dashboard and it is published
return null;
}

View File

@@ -17,13 +17,13 @@
* under the License.
*/
/* eslint-env browser */
import { Component } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
// @ts-expect-error
import { createFilter } from 'react-search-input';
import { t } from '@apache-superset/core/translation';
import { styled, css } from '@apache-superset/core/theme';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import {
Button,
Checkbox,
@@ -49,7 +49,6 @@ import {
import { debounce, pickBy } from 'lodash';
import { Dispatch } from 'redux';
import { Slice } from 'src/dashboard/types';
import { withTheme, Theme } from '@emotion/react';
import { navigateTo } from 'src/utils/navigationUtils';
import type { ConnectDragSource } from 'react-dnd';
import AddSliceCard from './AddSliceCard';
@@ -58,7 +57,6 @@ import { DragDroppable } from './dnd/DragDroppable';
import { datasetLabelLower } from 'src/features/semanticLayers/label';
export type SliceAdderProps = {
theme: Theme;
fetchSlices: (
userId?: number,
filter_value?: string,
@@ -77,14 +75,6 @@ export type SliceAdderProps = {
dashboardId: number;
};
type SliceAdderState = {
filteredSlices: Slice[];
searchTerm: string;
sortBy: keyof Slice;
selectedSliceIdsSet: Set<number>;
showOnlyMyCharts: boolean;
};
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
const KEYS_TO_SORT = {
slice_name: t('name'),
@@ -174,295 +164,308 @@ function getFilteredSortedSlices(
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
.sort(sortByComparator(sortBy));
}
class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
private slicesRequest?: AbortController | Promise<void>;
static defaultProps = {
selectedSliceIds: [],
editMode: false,
errorMessage: '',
};
function SliceAdder({
fetchSlices,
updateSlices,
isLoading,
slices,
errorMessage = '',
userId,
selectedSliceIds = [],
editMode = false,
dashboardId,
}: SliceAdderProps) {
const theme = useTheme();
const slicesRequestRef = useRef<AbortController | Promise<void>>();
constructor(props: SliceAdderProps) {
super(props);
this.state = {
filteredSlices: [],
searchTerm: '',
sortBy: DEFAULT_SORT_KEY,
selectedSliceIdsSet: new Set(props.selectedSliceIds),
showOnlyMyCharts: getItem(
LocalStorageKeys.DashboardEditorShowOnlyMyCharts,
true,
),
};
this.rowRenderer = this.rowRenderer.bind(this);
this.searchUpdated = this.searchUpdated.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.userIdForFetch = this.userIdForFetch.bind(this);
this.onShowOnlyMyCharts = this.onShowOnlyMyCharts.bind(this);
}
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<keyof Slice>(DEFAULT_SORT_KEY);
const [selectedSliceIdsSet, setSelectedSliceIdsSet] = useState(
() => new Set(selectedSliceIds),
);
userIdForFetch() {
return this.state.showOnlyMyCharts ? this.props.userId : undefined;
}
// Refs to track latest values for cleanup effect
const latestSlicesRef = useRef(slices);
const latestSelectedSliceIdsSetRef = useRef(selectedSliceIdsSet);
const [showOnlyMyCharts, setShowOnlyMyCharts] = useState(() =>
getItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, true),
);
componentDidMount() {
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
'',
this.state.sortBy,
);
}
// Keep refs updated with latest values
useEffect(() => {
latestSlicesRef.current = slices;
}, [slices]);
componentDidUpdate(prevProps: SliceAdderProps) {
const nextState: SliceAdderState = {} as SliceAdderState;
if (this.props.lastUpdated !== prevProps.lastUpdated) {
nextState.filteredSlices = getFilteredSortedSlices(
this.props.slices,
this.state.searchTerm,
this.state.sortBy,
this.state.showOnlyMyCharts,
this.props.userId,
);
}
useEffect(() => {
latestSelectedSliceIdsSetRef.current = selectedSliceIdsSet;
}, [selectedSliceIdsSet]);
if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) {
nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds);
}
if (Object.keys(nextState).length) {
this.setState(nextState);
}
}
componentWillUnmount() {
// Clears the redux store keeping only selected items
const selectedSlices = pickBy(this.props.slices, (value: Slice) =>
this.state.selectedSliceIdsSet.has(value.slice_id),
);
this.props.updateSlices(selectedSlices);
if (this.slicesRequest instanceof AbortController) {
this.slicesRequest.abort();
}
}
handleChange = debounce(value => {
this.searchUpdated(value);
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
value,
this.state.sortBy,
);
}, 300);
searchUpdated(searchTerm: string) {
this.setState(prevState => ({
searchTerm,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
const filteredSlices = useMemo(
() =>
getFilteredSortedSlices(
slices,
searchTerm,
prevState.sortBy,
prevState.showOnlyMyCharts,
this.props.userId,
),
}));
}
handleSelect(sortBy: keyof Slice) {
this.setState(prevState => ({
sortBy,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
prevState.searchTerm,
sortBy,
prevState.showOnlyMyCharts,
this.props.userId,
),
}));
this.slicesRequest = this.props.fetchSlices(
this.userIdForFetch(),
this.state.searchTerm,
sortBy,
);
}
rowRenderer({ index, style }: { index: number; style: React.CSSProperties }) {
const { filteredSlices, selectedSliceIdsSet } = this.state;
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={this.props.editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
}
onShowOnlyMyCharts = (showOnlyMyCharts: boolean) => {
if (!showOnlyMyCharts) {
this.slicesRequest = this.props.fetchSlices(
undefined,
this.state.searchTerm,
this.state.sortBy,
);
}
this.setState(prevState => ({
showOnlyMyCharts,
filteredSlices: getFilteredSortedSlices(
this.props.slices,
prevState.searchTerm,
prevState.sortBy,
showOnlyMyCharts,
this.props.userId,
userId,
),
}));
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts);
};
[slices, searchTerm, sortBy, showOnlyMyCharts, userId],
);
render() {
const { theme } = this.props;
return (
<div
css={css`
height: 100%;
display: flex;
flex-direction: column;
button > span > :first-of-type {
margin-right: 0;
const userIdForFetch = useCallback(
() => (showOnlyMyCharts ? userId : undefined),
[showOnlyMyCharts, userId],
);
// Refs so the debounced search reads the latest sortBy/userIdForFetch at
// fire time without recreating the debounce (which would drop a pending,
// armed-but-not-yet-fired search when sortBy/showOnlyMyCharts change).
const sortByRef = useRef(sortBy);
const userIdForFetchRef = useRef(userIdForFetch);
useEffect(() => {
sortByRef.current = sortBy;
}, [sortBy]);
useEffect(() => {
userIdForFetchRef.current = userIdForFetch;
}, [userIdForFetch]);
// componentDidMount
useEffect(() => {
slicesRequestRef.current = fetchSlices(userIdForFetch(), '', sortBy);
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update selectedSliceIdsSet when selectedSliceIds prop changes
useEffect(() => {
setSelectedSliceIdsSet(new Set(selectedSliceIds));
}, [selectedSliceIds]);
// componentWillUnmount
useEffect(
() => () => {
// Clears the redux store keeping only selected items
// Use refs to get latest values on unmount
const selectedSlices = pickBy(latestSlicesRef.current, (value: Slice) =>
latestSelectedSliceIdsSetRef.current.has(value.slice_id),
);
updateSlices(selectedSlices);
if (slicesRequestRef.current instanceof AbortController) {
slicesRequestRef.current.abort();
}
},
[updateSlices],
);
const searchUpdated = useCallback((term: string) => {
setSearchTerm(term);
}, []);
const fetchSlicesRef = useRef(fetchSlices);
useEffect(() => {
fetchSlicesRef.current = fetchSlices;
}, [fetchSlices]);
// Create the debounce once (stable identity) so a pending search isn't
// dropped when sortBy/userIdForFetch change mid-typing. The debounced
// function reads the latest values from refs at fire time.
const handleChange = useMemo(
() =>
debounce((value: string) => {
searchUpdated(value);
slicesRequestRef.current = fetchSlicesRef.current(
userIdForFetchRef.current(),
value,
sortByRef.current,
);
}, 300),
[searchUpdated],
);
useEffect(
() => () => {
handleChange.cancel();
},
[handleChange],
);
const handleSelect = useCallback(
(newSortBy: keyof Slice) => {
setSortBy(newSortBy);
slicesRequestRef.current = fetchSlices(
userIdForFetch(),
searchTerm,
newSortBy,
);
},
[fetchSlices, searchTerm, userIdForFetch],
);
const onShowOnlyMyCharts = useCallback(
(checked: boolean) => {
if (!checked) {
slicesRequestRef.current = fetchSlices(undefined, searchTerm, sortBy);
}
setShowOnlyMyCharts(checked);
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, checked);
},
[fetchSlices, searchTerm, sortBy],
);
const rowRenderer = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
},
[filteredSlices, selectedSliceIdsSet, editMode],
);
return (
<div
css={css`
height: 100%;
display: flex;
flex-direction: column;
button > span > :first-of-type {
margin-right: 0;
}
`}
>
<NewChartButtonContainer>
<NewChartButton
buttonStyle="link"
buttonSize="xsmall"
icon={
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
}
onClick={() =>
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
})
}
>
{t('Create new chart')}
</NewChartButton>
</NewChartButtonContainer>
<Controls>
<Input
placeholder={
showOnlyMyCharts ? t('Filter your charts') : t('Filter charts')
}
className="search-input"
onChange={ev => handleChange(ev.target.value)}
data-test="dashboard-charts-filter-search-input"
/>
<StyledSelect
id="slice-adder-sortby"
value={sortBy}
onChange={handleSelect}
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
label: t('Sort by %s', label),
value: key,
}))}
placeholder={t('Sort by')}
/>
</Controls>
<div
css={themeObj => css`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: ${themeObj.sizeUnit}px;
padding: 0 ${themeObj.sizeUnit * 3}px ${themeObj.sizeUnit * 4}px
${themeObj.sizeUnit * 3}px;
`}
>
<NewChartButtonContainer>
<NewChartButton
buttonStyle="link"
buttonSize="xsmall"
icon={
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
}
onClick={() =>
navigateTo(`/chart/add?dashboard_id=${this.props.dashboardId}`, {
newWindow: true,
})
}
>
{t('Create new chart')}
</NewChartButton>
</NewChartButtonContainer>
<Controls>
<Input
placeholder={
this.state.showOnlyMyCharts
? t('Filter your charts')
: t('Filter charts')
}
className="search-input"
onChange={ev => this.handleChange(ev.target.value)}
data-test="dashboard-charts-filter-search-input"
/>
<StyledSelect
id="slice-adder-sortby"
value={this.state.sortBy}
onChange={this.handleSelect}
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
label: t('Sort by %s', label),
value: key,
}))}
placeholder={t('Sort by')}
/>
</Controls>
<Checkbox
onChange={e => onShowOnlyMyCharts(e.target.checked)}
checked={showOnlyMyCharts}
/>
{t('Show only my charts')}
<InfoTooltip
placement="top"
tooltip={t(
`You can choose to display all charts that you have access to or only the ones you own.
Your filter selection will be saved and remain active until you choose to change it.`,
)}
/>
</div>
{isLoading && <Loading />}
{!isLoading && filteredSlices.length > 0 && (
<ChartList>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => filteredSlices[index].slice_id}
>
{rowRenderer}
</List>
)}
</AutoSizer>
</ChartList>
)}
{errorMessage && (
<div
css={theme => css`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: ${theme.sizeUnit}px;
padding: 0 ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px
${theme.sizeUnit * 3}px;
css={css`
padding: 16px;
`}
>
<Checkbox
onChange={e => this.onShowOnlyMyCharts(e.target.checked)}
checked={this.state.showOnlyMyCharts}
/>
{t('Show only my charts')}
<InfoTooltip
placement="top"
tooltip={t(
`You can choose to display all charts that you have access to or only the ones you own.
Your filter selection will be saved and remain active until you choose to change it.`,
)}
/>
{errorMessage}
</div>
{this.props.isLoading && <Loading />}
{!this.props.isLoading && this.state.filteredSlices.length > 0 && (
<ChartList>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={this.state.filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => this.state.filteredSlices[index].slice_id}
>
{this.rowRenderer}
</List>
)}
</AutoSizer>
</ChartList>
)}
{this.props.errorMessage && (
<div
css={css`
padding: 16px;
`}
>
{this.props.errorMessage}
</div>
)}
{/* Drag preview is just a single fixed-position element */}
<AddSliceDragPreview slices={this.state.filteredSlices} />
</div>
);
}
)}
{/* Drag preview is just a single fixed-position element */}
<AddSliceDragPreview slices={filteredSlices} />
</div>
);
}
export default withTheme(SliceAdder);
export default SliceAdder;

View File

@@ -43,6 +43,40 @@ test('triggers onRedo', () => {
expect(onRedo).toHaveBeenCalledTimes(1);
});
test('triggers onRedo with Ctrl+Shift+Z', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
fireEvent.keyDown(document.body, {
key: 'z',
keyCode: 90,
ctrlKey: true,
shiftKey: true,
});
expect(onRedo).toHaveBeenCalledTimes(1);
expect(onUndo).not.toHaveBeenCalled();
});
test('triggers onUndo via keyCode fallback for non-Latin layouts', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
// event.key is a non-'z' glyph (e.g. non-Latin layout), but code is KeyZ
fireEvent.keyDown(document.body, { key: 'я', code: 'KeyZ', ctrlKey: true });
expect(onUndo).toHaveBeenCalledTimes(1);
expect(onRedo).not.toHaveBeenCalled();
});
test('triggers onRedo via keyCode fallback for non-Latin layouts', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
// event.key is a non-'y' glyph, but code is KeyY
fireEvent.keyDown(document.body, { key: 'н', code: 'KeyY', ctrlKey: true });
expect(onRedo).toHaveBeenCalledTimes(1);
expect(onUndo).not.toHaveBeenCalled();
});
test('does not trigger when it is another key', () => {
const onUndo = jest.fn();
const onRedo = jest.fn();

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useCallback, useEffect } from 'react';
import { HeaderProps } from '../Header/types';
type UndoRedoKeyListenersProps = {
@@ -24,43 +24,43 @@ type UndoRedoKeyListenersProps = {
onRedo: HeaderProps['onRedo'];
};
class UndoRedoKeyListeners extends PureComponent<UndoRedoKeyListenersProps> {
constructor(props: UndoRedoKeyListenersProps) {
super(props);
this.handleKeydown = this.handleKeydown.bind(this);
}
function UndoRedoKeyListeners({ onUndo, onRedo }: UndoRedoKeyListenersProps) {
const handleKeydown = useCallback(
(event: KeyboardEvent) => {
const controlOrCommand = event.ctrlKey || event.metaKey;
if (controlOrCommand) {
const key = event.key.toLowerCase();
// Fall back to event.code (the physical key) so undo/redo still work on
// non-Latin keyboard layouts where event.key is a different glyph.
const isZ = key === 'z' || event.code === 'KeyZ';
const isY = key === 'y' || event.code === 'KeyY';
const isUndo = isZ && !event.shiftKey;
const isRedo = isY || (isZ && event.shiftKey);
const isEditingMarkdown = document?.querySelector(
'.dashboard-markdown--editing',
);
const isEditingTitle = document?.querySelector(
'.editable-title--editing',
);
componentDidMount() {
document.addEventListener('keydown', this.handleKeydown);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.handleKeydown);
}
handleKeydown(event: KeyboardEvent) {
const controlOrCommand = event.ctrlKey || event.metaKey;
if (controlOrCommand) {
const isZChar = event.key === 'z' || event.keyCode === 90;
const isYChar = event.key === 'y' || event.keyCode === 89;
const isEditingMarkdown = document?.querySelector(
'.dashboard-markdown--editing',
);
const isEditingTitle = document?.querySelector(
'.editable-title--editing',
);
if (!isEditingMarkdown && !isEditingTitle && (isZChar || isYChar)) {
event.preventDefault();
const func = isZChar ? this.props.onUndo : this.props.onRedo;
func();
if (!isEditingMarkdown && !isEditingTitle && (isUndo || isRedo)) {
event.preventDefault();
const func = isUndo ? onUndo : onRedo;
func();
}
}
}
}
},
[onUndo, onRedo],
);
render() {
return null;
}
useEffect(() => {
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [handleKeydown]);
return null;
}
export default UndoRedoKeyListeners;

View File

@@ -33,7 +33,6 @@ import {
} from 'react-dnd';
import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme';
import { dragConfig, dropConfig } from './dragDroppableConfig';
import type { DragDroppableProps as BaseDragDroppableProps } from './dragDroppableConfig';
import { DROP_FORBIDDEN } from '../../util/getDropPosition';
@@ -122,15 +121,22 @@ const DragDroppableStyles = styled.div`
}
`};
`;
/**
* Note: This component remains a class component because it is tightly integrated
* with react-dnd's class-based HOC system (DragSource/DropTarget). The HOCs
* access component instance properties directly (mounted, ref, props, setState)
* in the hover/drop callbacks defined in dragDroppableConfig.ts.
*
* Converting to a function component would require migrating to react-dnd's
* hooks API (useDrag/useDrop), which would be a more extensive refactor.
*/
// export unwrapped component for testing
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- react-dnd class-based HOC requires class component instance properties
export class UnwrappedDragDroppable extends PureComponent<
DragDroppableAllProps,
DragDroppableState
> {
mounted: boolean;
ref: HTMLDivElement | null;
static defaultProps = {
className: null,
style: null,
@@ -152,6 +158,10 @@ export class UnwrappedDragDroppable extends PureComponent<
dragPreviewRef() {},
};
mounted: boolean;
ref: HTMLDivElement | null;
constructor(props: DragDroppableAllProps) {
super(props);
this.state = {
@@ -283,7 +293,6 @@ export class UnwrappedDragDroppable extends PureComponent<
// react-dnd's DragSource/DropTarget HOC types don't play well with
// class components using spread config tuples, so we use type assertions here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DragDroppableAsAny =
UnwrappedDragDroppable as unknown as ReactComponentType<
Record<string, unknown>

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createRef, PureComponent } from 'react';
import { useRef, useCallback } from 'react';
import { styled } from '@apache-superset/core/theme';
import {
ModalTrigger,
@@ -33,39 +33,29 @@ const FilterScopeModalBody = styled.div(({ theme: { sizeUnit } }) => ({
paddingBottom: sizeUnit * 3,
}));
export default class FilterScopeModal extends PureComponent<
FilterScopeModalProps,
{}
> {
modal: ModalTriggerRef;
export default function FilterScopeModal({
triggerNode,
}: FilterScopeModalProps) {
const modalRef = useRef<ModalTriggerRef['current']>(null);
constructor(props: FilterScopeModalProps) {
super(props);
const handleCloseModal = useCallback((): void => {
modalRef.current?.close?.();
}, []);
this.modal = createRef() as ModalTriggerRef;
this.handleCloseModal = this.handleCloseModal.bind(this);
}
const filterScopeProps = {
onCloseModal: handleCloseModal,
};
handleCloseModal(): void {
this?.modal?.current?.close?.();
}
render() {
const filterScopeProps = {
onCloseModal: this.handleCloseModal,
};
return (
<ModalTrigger
ref={this.modal}
triggerNode={this.props.triggerNode}
modalBody={
<FilterScopeModalBody>
<FilterScope {...filterScopeProps} />
</FilterScopeModalBody>
}
width="80%"
/>
);
}
return (
<ModalTrigger
ref={modalRef}
triggerNode={triggerNode}
modalBody={
<FilterScopeModalBody>
<FilterScope {...filterScopeProps} />
</FilterScopeModalBody>
}
width="80%"
/>
);
}

View File

@@ -0,0 +1,265 @@
/**
* 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 {
cleanup,
render,
screen,
userEvent,
} from 'spec/helpers/testing-library';
import FilterScopeSelector from './FilterScopeSelector';
import type { DashboardLayout } from 'src/dashboard/types';
// --- Mock child components ---
jest.mock('./FilterFieldTree', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => (
<div data-test="filter-field-tree">
FilterFieldTree (checked={String(props.checked)})
</div>
),
}));
jest.mock('./FilterScopeTree', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => (
<div data-test="filter-scope-tree">
FilterScopeTree (checked={String(props.checked)})
</div>
),
}));
// --- Mock utility functions ---
jest.mock('src/dashboard/util/getFilterFieldNodesTree', () => ({
__esModule: true,
default: jest.fn(() => [
{
value: 'ALL_FILTERS_ROOT',
label: 'All filters',
children: [
{
value: 1,
label: 'Filter A',
children: [
{ value: '1_column_b', label: 'Filter B' },
{ value: '1_column_c', label: 'Filter C' },
],
},
],
},
]),
}));
jest.mock('src/dashboard/util/getFilterScopeNodesTree', () => ({
__esModule: true,
default: jest.fn(() => [
{
value: 'ROOT_ID',
label: 'All charts',
children: [{ value: 2, label: 'Chart A' }],
},
]),
}));
jest.mock('src/dashboard/util/getFilterScopeParentNodes', () => ({
__esModule: true,
default: jest.fn(() => ['ROOT_ID']),
}));
jest.mock('src/dashboard/util/buildFilterScopeTreeEntry', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
jest.mock('src/dashboard/util/getKeyForFilterScopeTree', () => ({
__esModule: true,
default: jest.fn(() => '1_column_b'),
}));
jest.mock('src/dashboard/util/getSelectedChartIdForFilterScopeTree', () => ({
__esModule: true,
default: jest.fn(() => 1),
}));
jest.mock('src/dashboard/util/getFilterScopeFromNodesTree', () => ({
__esModule: true,
default: jest.fn(() => ({ scope: ['ROOT_ID'], immune: [] })),
}));
jest.mock('src/dashboard/util/getRevertedFilterScope', () => ({
__esModule: true,
default: jest.fn(() => ({})),
}));
jest.mock('src/dashboard/util/activeDashboardFilters', () => ({
getChartIdsInFilterScope: jest.fn(() => [2, 3]),
}));
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
const mockDashboardFilters = {
1: {
chartId: 1,
componentId: 'component-1',
filterName: 'Filter A',
datasourceId: 'ds-1',
directPathToFilter: ['ROOT_ID', 'GRID', 'CHART_1'],
isDateFilter: false,
isInstantFilter: false,
columns: { column_b: undefined, column_c: undefined },
labels: { column_b: 'Filter B', column_c: 'Filter C' },
scopes: {
column_b: { immune: [], scope: ['ROOT_ID'] },
column_c: { immune: [], scope: ['ROOT_ID'] },
},
},
};
const mockLayout: DashboardLayout = {
ROOT_ID: { children: ['GRID'], id: 'ROOT_ID', type: 'ROOT' },
GRID: {
children: ['CHART_1', 'CHART_2'],
id: 'GRID',
type: 'GRID',
parents: ['ROOT_ID'],
},
CHART_1: {
meta: { chartId: 1, sliceName: 'Chart 1' },
children: [],
id: 'CHART_1',
type: 'CHART',
parents: ['ROOT_ID', 'GRID'],
},
CHART_2: {
meta: { chartId: 2, sliceName: 'Chart 2' },
children: [],
id: 'CHART_2',
type: 'CHART',
parents: ['ROOT_ID', 'GRID'],
},
} as unknown as DashboardLayout;
const defaultProps = {
dashboardFilters: mockDashboardFilters,
layout: mockLayout,
updateDashboardFiltersScope: jest.fn(),
setUnsavedChanges: jest.fn(),
onCloseModal: jest.fn(),
};
test('renders the header, filter field panel, and scope panel', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByText('Configure filter scopes')).toBeInTheDocument();
expect(screen.getByTestId('filter-field-tree')).toBeInTheDocument();
expect(screen.getByTestId('filter-scope-tree')).toBeInTheDocument();
});
test('renders the search input with correct placeholder', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveAttribute('type', 'text');
});
test('renders Close and Save buttons when filters exist', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
test('renders only Close button and a warning when no filters exist', () => {
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
useRedux: true,
});
expect(
screen.getByText('There are no filters in this dashboard.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Save' }),
).not.toBeInTheDocument();
});
test('does not render FilterFieldTree or FilterScopeTree when no filters exist', () => {
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
useRedux: true,
});
expect(screen.queryByTestId('filter-field-tree')).not.toBeInTheDocument();
expect(screen.queryByTestId('filter-scope-tree')).not.toBeInTheDocument();
});
test('calls onCloseModal when Close button is clicked', () => {
const onCloseModal = jest.fn();
render(
<FilterScopeSelector {...defaultProps} onCloseModal={onCloseModal} />,
{ useRedux: true },
);
userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onCloseModal).toHaveBeenCalledTimes(1);
});
test('calls updateDashboardFiltersScope, setUnsavedChanges, and onCloseModal when Save is clicked', () => {
const updateDashboardFiltersScope = jest.fn();
const setUnsavedChanges = jest.fn();
const onCloseModal = jest.fn();
render(
<FilterScopeSelector
{...defaultProps}
updateDashboardFiltersScope={updateDashboardFiltersScope}
setUnsavedChanges={setUnsavedChanges}
onCloseModal={onCloseModal}
/>,
{ useRedux: true },
);
userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(updateDashboardFiltersScope).toHaveBeenCalledTimes(1);
expect(setUnsavedChanges).toHaveBeenCalledWith(true);
expect(onCloseModal).toHaveBeenCalledTimes(1);
});
test('renders the editing filters name section with "Editing 1 filter:" label', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
expect(screen.getByText('Editing 1 filter:')).toBeInTheDocument();
// The active filter label should appear (column_b maps to "Filter B")
expect(screen.getByText('Filter B')).toBeInTheDocument();
});
test('updates search text when typing in the search input', () => {
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
const searchInput = screen.getByPlaceholderText('Search...');
userEvent.type(searchInput, 'Chart');
expect(searchInput).toHaveValue('Chart');
});

View File

@@ -16,12 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, ChangeEvent, type ReactElement } from 'react';
import {
useState,
useCallback,
useMemo,
ChangeEvent,
type ReactElement,
} from 'react';
import cx from 'classnames';
import { Button, Input } from '@superset-ui/core/components';
import { css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import buildFilterScopeTreeEntry from 'src/dashboard/util/buildFilterScopeTreeEntry';
import getFilterScopeNodesTree from 'src/dashboard/util/getFilterScopeNodesTree';
import getFilterFieldNodesTree from 'src/dashboard/util/getFilterFieldNodesTree';
@@ -90,30 +95,6 @@ export interface FilterScopeSelectorProps {
onCloseModal: () => void;
}
interface FilterScopeSelectorStateWithSelector {
showSelector: true;
activeFilterField: string | null;
searchText: string;
filterScopeMap: FilterScopeMap;
filterFieldNodes: FilterFieldNode[];
checkedFilterFields: string[];
expandedFilterIds: (string | number)[];
}
interface FilterScopeSelectorStateWithoutSelector {
showSelector: false;
activeFilterField?: undefined;
searchText?: undefined;
filterScopeMap?: undefined;
filterFieldNodes?: undefined;
checkedFilterFields?: undefined;
expandedFilterIds?: undefined;
}
type FilterScopeSelectorState =
| FilterScopeSelectorStateWithSelector
| FilterScopeSelectorStateWithoutSelector;
const ScopeContainer = styled.div`
${({ theme }) => css`
display: flex;
@@ -389,271 +370,358 @@ const ActionsContainer = styled.div`
`}
`;
export default class FilterScopeSelector extends PureComponent<
FilterScopeSelectorProps,
FilterScopeSelectorState
> {
allfilterFields: string[];
function initializeState(
dashboardFilters: Record<number, DashboardFilter>,
layout: DashboardLayout,
) {
if (Object.keys(dashboardFilters).length === 0) {
return {
showSelector: false as const,
allFilterFields: [] as string[],
defaultFilterKey: '',
};
}
defaultFilterKey: string;
// display filter fields in tree structure
const filterFieldNodes = getFilterFieldNodesTree({
dashboardFilters,
});
// filterFieldNodes root node is dashboard_root component,
// so that we can offer a select/deselect all link
const filtersNodes = filterFieldNodes[0].children ?? [];
const allFilterFields: string[] = [];
filtersNodes.forEach(({ children }) => {
(children ?? []).forEach(child => {
allFilterFields.push(String(child.value));
});
});
const defaultFilterKey = String(filtersNodes[0]?.children?.[0]?.value ?? '');
constructor(props: FilterScopeSelectorProps) {
super(props);
this.allfilterFields = [];
this.defaultFilterKey = '';
const { dashboardFilters, layout } = props;
if (Object.keys(dashboardFilters).length > 0) {
// display filter fields in tree structure
const filterFieldNodes = getFilterFieldNodesTree({
dashboardFilters,
});
// filterFieldNodes root node is dashboard_root component,
// so that we can offer a select/deselect all link
const filtersNodes = filterFieldNodes[0].children ?? [];
this.allfilterFields = [];
filtersNodes.forEach(({ children }) => {
(children ?? []).forEach(child => {
this.allfilterFields.push(String(child.value));
// build FilterScopeTree object for each filterKey
const filterScopeMap: FilterScopeMap = Object.values(
dashboardFilters,
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
const filterScopeByChartId = Object.keys(columns).reduce<FilterScopeMap>(
(mapByChartId, columnName) => {
const filterKey = getDashboardFilterKey({
chartId: String(filterId),
column: columnName,
});
});
this.defaultFilterKey = String(
filtersNodes[0]?.children?.[0]?.value ?? '',
);
// build FilterScopeTree object for each filterKey
const filterScopeMap: FilterScopeMap = Object.values(
dashboardFilters,
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
const filterScopeByChartId = Object.keys(
columns,
).reduce<FilterScopeMap>((mapByChartId, columnName) => {
const filterKey = getDashboardFilterKey({
chartId: String(filterId),
column: columnName,
});
const nodes = getFilterScopeNodesTree({
components: layout,
filterFields: [filterKey],
selectedChartId: filterId,
});
const expanded = getFilterScopeParentNodes(nodes, 1);
const chartIdsInFilterScope = (
getChartIdsInFilterScope({
filterScope: dashboardFilters[filterId].scopes[columnName],
}) || []
).filter((id: number) => id !== filterId);
return {
...mapByChartId,
[filterKey]: {
// unfiltered nodes
nodes,
// filtered nodes in display if searchText is not empty
nodesFiltered: [...nodes],
checked: chartIdsInFilterScope,
expanded,
},
};
}, {});
const nodes = getFilterScopeNodesTree({
components: layout,
filterFields: [filterKey],
selectedChartId: filterId,
});
const expanded = getFilterScopeParentNodes(nodes, 1);
const chartIdsInFilterScope = (
getChartIdsInFilterScope({
filterScope: dashboardFilters[filterId].scopes[columnName],
}) || []
).filter((id: number) => id !== filterId);
return {
...map,
...filterScopeByChartId,
...mapByChartId,
[filterKey]: {
// unfiltered nodes
nodes,
// filtered nodes in display if searchText is not empty
nodesFiltered: [...nodes],
checked: chartIdsInFilterScope,
expanded,
},
};
}, {});
// initial state: active defaultFilerKey
const { chartId } = getChartIdAndColumnFromFilterKey(
this.defaultFilterKey,
);
const checkedFilterFields: string[] = [];
const activeFilterField = this.defaultFilterKey;
// expand defaultFilterKey in filter field tree
const expandedFilterIds: (string | number)[] = [
ALL_FILTERS_ROOT,
chartId,
];
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField,
filterScopeMap,
layout,
});
this.state = {
showSelector: true,
activeFilterField,
searchText: '',
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
} as FilterScopeMap,
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
};
} else {
this.state = {
showSelector: false,
};
}
this.filterNodes = this.filterNodes.bind(this);
this.onChangeFilterField = this.onChangeFilterField.bind(this);
this.onCheckFilterScope = this.onCheckFilterScope.bind(this);
this.onExpandFilterScope = this.onExpandFilterScope.bind(this);
this.onSearchInputChange = this.onSearchInputChange.bind(this);
this.onCheckFilterField = this.onCheckFilterField.bind(this);
this.onExpandFilterField = this.onExpandFilterField.bind(this);
this.onClose = this.onClose.bind(this);
this.onSave = this.onSave.bind(this);
}
onCheckFilterScope(checked: (string | number)[] = []): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, filterScopeMap, checkedFilterFields } = state;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const editingList = activeFilterField
? [activeFilterField]
: checkedFilterFields;
const updatedEntry = {
...filterScopeMap[key],
checked,
};
const updatedFilterScopeMap = getRevertedFilterScope({
checked,
filterFields: editingList,
filterScopeMap,
});
this.setState(() => ({
filterScopeMap: {
...filterScopeMap,
...updatedFilterScopeMap,
[key]: updatedEntry,
} as FilterScopeMap,
}));
}
onExpandFilterScope(expanded: string[] = []): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = state;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const updatedEntry = {
...filterScopeMap[key],
expanded,
};
this.setState(() => ({
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
}));
}
{},
);
onCheckFilterField(checkedFilterFields: string[] = []): void {
const { layout } = this.props;
const state = this.state as FilterScopeSelectorStateWithSelector;
const { filterScopeMap } = state;
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
return {
...map,
...filterScopeByChartId,
};
}, {});
this.setState(() => ({
activeFilterField: null,
checkedFilterFields,
// initial state: active defaultFilerKey
const { chartId } = getChartIdAndColumnFromFilterKey(defaultFilterKey);
const checkedFilterFields: string[] = [];
const activeFilterField = defaultFilterKey;
// expand defaultFilterKey in filter field tree
const expandedFilterIds: (string | number)[] = [ALL_FILTERS_ROOT, chartId];
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField,
filterScopeMap,
layout,
});
return {
showSelector: true as const,
allFilterFields,
defaultFilterKey,
initialState: {
activeFilterField,
searchText: '',
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
},
}));
}
onExpandFilterField(expandedFilterIds: (string | number)[] = []): void {
this.setState(() => ({
expandedFilterIds,
}));
}
onChangeFilterField(filterField: { value?: string } = {}): void {
const { layout } = this.props;
const nextActiveFilterField = filterField.value;
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
activeFilterField: currentActiveFilterField,
} as FilterScopeMap,
filterFieldNodes,
checkedFilterFields,
filterScopeMap,
} = state;
expandedFilterIds,
},
};
}
// we allow single edit and multiple edit in the same view.
// if user click on the single filter field,
// will show filter scope for the single field.
// if user click on the same filter filed again,
// will toggle off the single filter field,
// and allow multi-edit all checked filter fields.
if (nextActiveFilterField === currentActiveFilterField) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
export default function FilterScopeSelector({
dashboardFilters,
layout,
updateDashboardFiltersScope,
setUnsavedChanges,
onCloseModal,
}: FilterScopeSelectorProps): ReactElement {
const initialized = useMemo(
() => initializeState(dashboardFilters, layout),
// Only initialize once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const { showSelector, allFilterFields } = initialized;
const [activeFilterField, setActiveFilterField] = useState<string | null>(
() =>
initialized.showSelector
? initialized.initialState.activeFilterField
: null,
);
const [searchText, setSearchText] = useState(() =>
initialized.showSelector ? initialized.initialState.searchText : '',
);
const [filterScopeMap, setFilterScopeMap] = useState<FilterScopeMap>(() =>
initialized.showSelector ? initialized.initialState.filterScopeMap : {},
);
const [filterFieldNodes] = useState<FilterFieldNode[]>(() =>
initialized.showSelector ? initialized.initialState.filterFieldNodes : [],
);
const [checkedFilterFields, setCheckedFilterFields] = useState<string[]>(
() =>
initialized.showSelector
? initialized.initialState.checkedFilterFields
: [],
);
const [expandedFilterIds, setExpandedFilterIds] = useState<
(string | number)[]
>(() =>
initialized.showSelector ? initialized.initialState.expandedFilterIds : [],
);
const filterNodes = useCallback(
(
filtered: FilterScopeTreeNode[] = [],
node: FilterScopeTreeNode = { value: '', label: '' },
currentSearchText: string,
): FilterScopeTreeNode[] => {
const filterNodesRecursive = (
f: FilterScopeTreeNode[],
n: FilterScopeTreeNode,
): FilterScopeTreeNode[] => filterNodes(f, n, currentSearchText);
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
filterNodesRecursive,
[],
);
if (
// Node's label matches the search string
node.label
.toLocaleLowerCase()
.indexOf((currentSearchText ?? '').toLocaleLowerCase()) > -1 ||
// Or a children has a matching node
children.length
) {
filtered.push({ ...node, children });
}
return filtered;
},
[],
);
const filterTree = useCallback(
(currentSearchText: string) => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
// Reset nodes back to unfiltered state
if (!currentSearchText) {
setFilterScopeMap(prev => ({
...prev,
[key]: {
...prev[key],
nodesFiltered: prev[key].nodes,
},
}));
} else {
setFilterScopeMap(prev => {
const nodesFiltered = prev[key].nodes.reduce<FilterScopeTreeNode[]>(
(filtered, node) => filterNodes(filtered, node, currentSearchText),
[],
);
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
return {
...prev,
[key]: {
...prev[key],
nodesFiltered,
expanded,
},
};
});
}
},
[activeFilterField, checkedFilterFields, filterNodes],
);
const onCheckFilterScope = useCallback(
(checked: (string | number)[] = []): void => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const editingList = activeFilterField
? [activeFilterField]
: checkedFilterFields;
const updatedFilterScopeMap = getRevertedFilterScope({
checked,
filterFields: editingList,
filterScopeMap,
});
setFilterScopeMap({
...filterScopeMap,
...updatedFilterScopeMap,
[key]: {
...filterScopeMap[key],
checked,
},
} as FilterScopeMap);
},
[activeFilterField, checkedFilterFields, filterScopeMap],
);
const onExpandFilterScope = useCallback(
(expanded: string[] = []): void => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
setFilterScopeMap(prev => ({
...prev,
[key]: {
...prev[key],
expanded,
},
}));
},
[activeFilterField, checkedFilterFields],
);
const onCheckFilterField = useCallback(
(newCheckedFilterFields: string[] = []): void => {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields: newCheckedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
this.setState({
activeFilterField: null,
filterScopeMap: {
setActiveFilterField(null);
setCheckedFilterFields(newCheckedFilterFields);
setFilterScopeMap({
...filterScopeMap,
...filterScopeTreeEntry,
});
},
[filterScopeMap, layout],
);
const onExpandFilterField = useCallback(
(newExpandedFilterIds: (string | number)[] = []): void => {
setExpandedFilterIds(newExpandedFilterIds);
},
[],
);
const onChangeFilterField = useCallback(
(filterField: { value?: string } = {}): void => {
const nextActiveFilterField = filterField.value;
// we allow single edit and multiple edit in the same view.
// if user click on the single filter field,
// will show filter scope for the single field.
// if user click on the same filter filed again,
// will toggle off the single filter field,
// and allow multi-edit all checked filter fields.
if (nextActiveFilterField === activeFilterField) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: undefined,
filterScopeMap,
layout,
});
setActiveFilterField(null);
setFilterScopeMap({
...filterScopeMap,
...filterScopeTreeEntry,
} as FilterScopeMap,
});
} else if (
nextActiveFilterField &&
this.allfilterFields.includes(nextActiveFilterField)
) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: nextActiveFilterField,
filterScopeMap,
layout,
});
});
} else if (
nextActiveFilterField &&
allFilterFields.includes(nextActiveFilterField)
) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: nextActiveFilterField,
filterScopeMap,
layout,
});
this.setState({
activeFilterField: nextActiveFilterField,
filterScopeMap: {
setActiveFilterField(nextActiveFilterField);
setFilterScopeMap({
...filterScopeMap,
...filterScopeTreeEntry,
} as FilterScopeMap,
});
}
}
});
}
},
[
activeFilterField,
allFilterFields,
checkedFilterFields,
filterScopeMap,
layout,
],
);
onSearchInputChange(e: ChangeEvent<HTMLInputElement>): void {
this.setState({ searchText: e.target.value }, this.filterTree);
}
const onSearchInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>): void => {
const newSearchText = e.target.value;
setSearchText(newSearchText);
filterTree(newSearchText);
},
[filterTree],
);
onClose(): void {
this.props.onCloseModal();
}
const onClose = useCallback((): void => {
onCloseModal();
}, [onCloseModal]);
onSave(): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { filterScopeMap } = state;
const allFilterFieldScopes = this.allfilterFields.reduce<
const onSave = useCallback((): void => {
const allFilterFieldScopes = allFilterFields.reduce<
Record<string, ReturnType<typeof getFilterScopeFromNodesTree>>
>((map, filterKey) => {
const { nodes } = filterScopeMap[filterKey];
@@ -669,124 +737,32 @@ export default class FilterScopeSelector extends PureComponent<
};
}, {});
this.props.updateDashboardFiltersScope(allFilterFieldScopes);
this.props.setUnsavedChanges(true);
updateDashboardFiltersScope(allFilterFieldScopes);
setUnsavedChanges(true);
// click Save button will do save and close modal
this.props.onCloseModal();
}
onCloseModal();
}, [
allFilterFields,
filterScopeMap,
onCloseModal,
setUnsavedChanges,
updateDashboardFiltersScope,
]);
filterTree(): void {
const state = this.state as FilterScopeSelectorStateWithSelector;
// Reset nodes back to unfiltered state
if (!state.searchText) {
this.setState(prevState => {
const prev = prevState as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const updatedEntry = {
...filterScopeMap[key],
nodesFiltered: filterScopeMap[key].nodes,
};
return {
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
});
} else {
const updater = (
prevState: FilterScopeSelectorState,
): FilterScopeSelectorState => {
const prev = prevState as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
});
const nodesFiltered = filterScopeMap[key].nodes.reduce<
FilterScopeTreeNode[]
>(this.filterNodes, []);
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
const updatedEntry = {
...filterScopeMap[key],
nodesFiltered,
expanded,
};
return {
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
};
this.setState(updater);
}
}
filterNodes(
filtered: FilterScopeTreeNode[] = [],
node: FilterScopeTreeNode = { value: '', label: '' },
): FilterScopeTreeNode[] {
const state = this.state as FilterScopeSelectorStateWithSelector;
const { searchText } = state;
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
this.filterNodes,
[],
);
if (
// Node's label matches the search string
node.label
.toLocaleLowerCase()
.indexOf((searchText ?? '').toLocaleLowerCase()) > -1 ||
// Or a children has a matching node
children.length
) {
filtered.push({ ...node, children });
}
return filtered;
}
renderFilterFieldList(): ReactElement | null {
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
activeFilterField,
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
} = state;
return (
<FilterFieldTree
activeKey={activeFilterField}
nodes={filterFieldNodes}
checked={checkedFilterFields}
expanded={expandedFilterIds}
onClick={this.onChangeFilterField}
onCheck={this.onCheckFilterField}
onExpand={this.onExpandFilterField}
/>
);
}
renderFilterScopeTree(): ReactElement {
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
filterScopeMap,
activeFilterField,
checkedFilterFields,
searchText,
} = state;
const renderFilterFieldList = (): ReactElement | null => (
<FilterFieldTree
activeKey={activeFilterField}
nodes={filterFieldNodes}
checked={checkedFilterFields}
expanded={expandedFilterIds}
onClick={onChangeFilterField}
onCheck={onCheckFilterField}
onExpand={onExpandFilterField}
/>
);
const renderFilterScopeTree = (): ReactElement => {
const key = getKeyForFilterScopeTree({
activeFilterField: activeFilterField ?? undefined,
checkedFilterFields,
@@ -803,26 +779,23 @@ export default class FilterScopeSelector extends PureComponent<
placeholder={t('Search...')}
type="text"
value={searchText}
onChange={this.onSearchInputChange}
onChange={onSearchInputChange}
/>
<FilterScopeTree
nodes={filterScopeMap[key].nodesFiltered}
checked={filterScopeMap[key].checked}
expanded={filterScopeMap[key].expanded}
onCheck={this.onCheckFilterScope}
onExpand={this.onExpandFilterScope}
onCheck={onCheckFilterScope}
onExpand={onExpandFilterScope}
// pass selectedFilterId prop to FilterScopeTree component,
// to hide checkbox for selected filter field itself
selectedChartId={selectedChartId}
/>
</>
);
}
};
renderEditingFiltersName(): ReactElement {
const { dashboardFilters } = this.props;
const state = this.state as FilterScopeSelectorStateWithSelector;
const { activeFilterField, checkedFilterFields } = state;
const renderEditingFiltersName = (): ReactElement => {
const currentFilterLabels = ([] as string[])
.concat(activeFilterField || checkedFilterFields)
.filter(Boolean)
@@ -842,50 +815,42 @@ export default class FilterScopeSelector extends PureComponent<
</span>
</div>
);
}
};
render(): ReactElement {
const { showSelector } = this.state;
return (
<ScopeContainer>
<ScopeHeader>
<h4>{t('Configure filter scopes')}</h4>
{showSelector && renderEditingFiltersName()}
</ScopeHeader>
return (
<ScopeContainer>
<ScopeHeader>
<h4>{t('Configure filter scopes')}</h4>
{showSelector && this.renderEditingFiltersName()}
</ScopeHeader>
<ScopeBody className="filter-scope-body">
{!showSelector ? (
<div className="warning-message">
{t('There are no filters in this dashboard.')}
<ScopeBody className="filter-scope-body">
{!showSelector ? (
<div className="warning-message">
{t('There are no filters in this dashboard.')}
</div>
) : (
<ScopeSelector className="filters-scope-selector">
<div className={cx('filter-field-pane multi-edit-mode')}>
{renderFilterFieldList()}
</div>
) : (
<ScopeSelector className="filters-scope-selector">
<div className={cx('filter-field-pane multi-edit-mode')}>
{this.renderFilterFieldList()}
</div>
<div className="filter-scope-pane multi-edit-mode">
{this.renderFilterScopeTree()}
</div>
</ScopeSelector>
)}
</ScopeBody>
<div className="filter-scope-pane multi-edit-mode">
{renderFilterScopeTree()}
</div>
</ScopeSelector>
)}
</ScopeBody>
<ActionsContainer>
<Button buttonSize="small" onClick={this.onClose}>
{t('Close')}
<ActionsContainer>
<Button buttonSize="small" onClick={onClose}>
{t('Close')}
</Button>
{showSelector && (
<Button buttonSize="small" buttonStyle="primary" onClick={onSave}>
{t('Save')}
</Button>
{showSelector && (
<Button
buttonSize="small"
buttonStyle="primary"
onClick={this.onSave}
>
{t('Save')}
</Button>
)}
</ActionsContainer>
</ScopeContainer>
);
}
)}
</ActionsContainer>
</ScopeContainer>
);
}

View File

@@ -128,6 +128,10 @@ const SliceContainer = styled.div`
const EMPTY_OBJECT: Record<string, never> = {};
// Stable no-op fallback for optional callbacks so we don't allocate a new
// function on every render (keeps referential equality for memoized children).
const NOOP = () => {};
// Helper function to get chart state with fallback
const getChartStateWithFallback = (
chartState: { state?: JsonObject } | undefined,
@@ -763,11 +767,11 @@ const Chart = (props: ChartProps) => {
},
slice.viz_type,
)}
queriesResponse={chart.queriesResponse ?? undefined}
queriesResponse={chart.queriesResponse ?? null}
timeout={timeout}
triggerQuery={chart.triggerQuery}
vizType={slice.viz_type}
setControlValue={props.setControlValue}
setControlValue={props.setControlValue ?? NOOP}
datasetsStatus={
datasetsStatus as 'loading' | 'error' | 'complete' | undefined
}

View File

@@ -17,9 +17,8 @@
* under the License.
*/
import { PureComponent } from 'react';
import { useCallback, memo } from 'react';
import { css, styled } from '@apache-superset/core/theme';
import { Draggable } from '../../dnd/DragDroppable';
import HoverMenu from '../../menu/HoverMenu';
import DeleteComponentButton from '../../DeleteComponentButton';
@@ -63,50 +62,43 @@ const DividerLine = styled.div`
`}
`;
class Divider extends PureComponent<DividerProps> {
constructor(props: DividerProps) {
super(props);
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
}
handleDeleteComponent() {
const { deleteComponent, id, parentId } = this.props;
function Divider({
id,
parentId,
component,
depth,
parentComponent,
index,
editMode,
handleComponentDrop,
deleteComponent,
}: DividerProps) {
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId);
}
}, [deleteComponent, id, parentId]);
render() {
const {
component,
depth,
parentComponent,
index,
handleComponentDrop,
editMode,
} = this.props;
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<div ref={dragSourceRef}>
{editMode && (
<HoverMenu position="left">
<DeleteComponentButton onDelete={this.handleDeleteComponent} />
</HoverMenu>
)}
<DividerLine className="dashboard-component dashboard-component-divider" />
</div>
)}
</Draggable>
);
}
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<div ref={dragSourceRef}>
{editMode && (
<HoverMenu position="left">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<DividerLine className="dashboard-component dashboard-component-divider" />
</div>
)}
</Draggable>
);
}
export default Divider;
export default memo(Divider);

View File

@@ -16,10 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useState, useCallback, memo } from 'react';
import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme';
import PopoverDropdown from '@superset-ui/core/components/PopoverDropdown';
import { EditableTitle } from '@superset-ui/core/components';
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
@@ -85,10 +84,6 @@ interface HeaderProps {
updateComponents: (changes: Record<string, ComponentShape>) => void;
}
interface HeaderState {
isFocused: boolean;
}
const HeaderStyles = styled.div`
${({ theme }) => css`
font-weight: ${theme.fontWeightStrong};
@@ -159,149 +154,141 @@ const HeaderStyles = styled.div`
`}
`;
class Header extends PureComponent<HeaderProps, HeaderState> {
handleChangeSize: (nextValue: string) => void;
handleChangeBackground: (nextValue: string) => void;
handleChangeText: (nextValue: string) => void;
function Header({
id,
dashboardId,
parentId,
component,
depth,
parentComponent,
index,
editMode,
embeddedMode,
handleComponentDrop,
deleteComponent,
updateComponents,
}: HeaderProps) {
const [isFocused, setIsFocused] = useState(false);
constructor(props: HeaderProps) {
super(props);
this.state = {
isFocused: false,
};
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
this.handleChangeFocus = this.handleChangeFocus.bind(this);
this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
const handleChangeFocus = useCallback((nextFocus: boolean): void => {
setIsFocused(nextFocus);
}, []);
this.handleChangeSize = (nextValue: string) =>
this.handleUpdateMeta('headerSize', nextValue);
this.handleChangeBackground = (nextValue: string) =>
this.handleUpdateMeta('background', nextValue);
this.handleChangeText = (nextValue: string) =>
this.handleUpdateMeta('text', nextValue);
}
handleChangeFocus(nextFocus: boolean): void {
this.setState(() => ({ isFocused: nextFocus }));
}
handleUpdateMeta(metaKey: keyof ComponentMeta, nextValue: string): void {
const { updateComponents, component } = this.props;
if (nextValue && component.meta[metaKey] !== nextValue) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
[metaKey]: nextValue,
const handleUpdateMeta = useCallback(
(metaKey: keyof ComponentMeta, nextValue: string): void => {
if (nextValue && component.meta[metaKey] !== nextValue) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
[metaKey]: nextValue,
},
},
},
} as Record<string, ComponentShape>);
}
}
} as Record<string, ComponentShape>);
}
},
[component, updateComponents],
);
handleDeleteComponent(): void {
const { deleteComponent, id, parentId } = this.props;
const handleChangeSize = useCallback(
(nextValue: string) => handleUpdateMeta('headerSize', nextValue),
[handleUpdateMeta],
);
const handleChangeBackground = useCallback(
(nextValue: string) => handleUpdateMeta('background', nextValue),
[handleUpdateMeta],
);
const handleChangeText = useCallback(
(nextValue: string) => handleUpdateMeta('text', nextValue),
[handleUpdateMeta],
);
const handleDeleteComponent = useCallback((): void => {
deleteComponent(id, parentId);
}
}, [deleteComponent, id, parentId]);
render() {
const { isFocused } = this.state;
const headerStyle = headerStyleOptions.find(
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
);
const {
dashboardId,
component,
depth,
parentComponent,
index,
handleComponentDrop,
editMode,
embeddedMode,
} = this.props;
const rowStyle = backgroundStyleOptions.find(
opt => opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
);
const headerStyle = headerStyleOptions.find(
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
);
const rowStyle = backgroundStyleOptions.find(
opt =>
opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
);
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({
dragSourceRef,
}: {
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
}) => (
<div ref={dragSourceRef}>
{editMode &&
depth <= 2 && ( // drag handle looks bad when nested
<HoverMenu position="left">
<DragHandle position="left" />
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({
dragSourceRef,
}: {
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
}) => (
<div ref={dragSourceRef}>
{editMode &&
depth <= 2 && ( // drag handle looks bad when nested
<HoverMenu position="left">
<DragHandle position="left" />
</HoverMenu>
)}
<WithPopoverMenu
onChangeFocus={handleChangeFocus}
menuItems={[
<PopoverDropdown
id={`${component.id}-header-style`}
options={headerStyleOptions}
value={component.meta.headerSize as string}
onChange={handleChangeSize}
/>,
<BackgroundStyleDropdown
id={`${component.id}-background`}
value={component.meta.background as string}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
<HeaderStyles
className={cx(
'dashboard-component',
'dashboard-component-header',
headerStyle?.className,
rowStyle?.className,
)}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
menuItems={[
<PopoverDropdown
id={`${component.id}-header-style`}
options={headerStyleOptions}
value={component.meta.headerSize as string}
onChange={this.handleChangeSize}
/>,
<BackgroundStyleDropdown
id={`${component.id}-background`}
value={component.meta.background as string}
onChange={this.handleChangeBackground}
/>,
]}
editMode={editMode}
>
<HeaderStyles
className={cx(
'dashboard-component',
'dashboard-component-header',
headerStyle?.className,
rowStyle?.className,
)}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
<EditableTitle
title={component.meta.text}
canEdit={editMode}
onSaveTitle={this.handleChangeText}
showTooltip={false}
<EditableTitle
title={component.meta.text}
canEdit={editMode}
onSaveTitle={handleChangeText}
showTooltip={false}
/>
{!editMode && !embeddedMode && (
<AnchorLink
id={component.id}
dashboardId={Number(dashboardId)}
/>
{!editMode && !embeddedMode && (
<AnchorLink
id={component.id}
dashboardId={Number(dashboardId)}
/>
)}
</HeaderStyles>
</WithPopoverMenu>
</div>
)}
</Draggable>
);
}
)}
</HeaderStyles>
</WithPopoverMenu>
</div>
)}
</Draggable>
);
}
export default Header;
export default memo(Header);

View File

@@ -16,14 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ErrorInfo } from 'react';
import { connect } from 'react-redux';
import cx from 'classnames';
import type { JsonObject } from '@superset-ui/core';
import type { ResizeStartCallback, ResizeCallback } from 're-resizable';
import { ErrorBoundary } from 'src/components';
import { css, styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import { SafeMarkdown } from '@superset-ui/core/components';
import { EditorHost } from 'src/core/editors';
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
@@ -82,16 +84,6 @@ export interface MarkdownStateProps {
export type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
export interface MarkdownState {
isFocused: boolean;
markdownSource: string;
editor: EditorInstance | null;
editorMode: 'preview' | 'edit';
undoLength: number;
redoLength: number;
hasError?: boolean;
}
// TODO: localize
const MARKDOWN_PLACE_HOLDER = `# ✨Header 1
## ✨Header 2
@@ -140,193 +132,199 @@ interface DragChildProps {
dragSourceRef: React.RefCallback<HTMLElement>;
}
class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
renderStartTime: number;
function Markdown({
id,
parentId,
component,
parentComponent,
index,
depth,
editMode,
availableColumnCount,
columnWidth,
onResizeStart,
onResize,
onResizeStop,
deleteComponent,
handleComponentDrop,
updateComponents,
logEvent,
addDangerToast,
undoLength,
redoLength,
htmlSanitization,
htmlSchemaOverrides,
}: MarkdownProps) {
const [isFocused, setIsFocused] = useState(false);
const [markdownSource, setMarkdownSource] = useState<string>(
component.meta.code as string,
);
const [editor, setEditorState] = useState<EditorInstance | null>(null);
const [editorMode, setEditorMode] = useState<'preview' | 'edit'>('preview');
const [hasError, setHasError] = useState(false);
constructor(props: MarkdownProps) {
super(props);
this.state = {
isFocused: false,
markdownSource: props.component.meta.code as string,
editor: null,
editorMode: 'preview',
undoLength: props.undoLength,
redoLength: props.redoLength,
};
this.renderStartTime = Logger.getTimestamp();
const renderStartTimeRef = useRef(Logger.getTimestamp());
const prevUndoLengthRef = useRef(undoLength);
const prevRedoLengthRef = useRef(redoLength);
const prevComponentWidthRef = useRef(component.meta.width);
const prevColumnWidthRef = useRef(columnWidth);
this.handleChangeFocus = this.handleChangeFocus.bind(this);
this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this);
this.handleMarkdownChange = this.handleMarkdownChange.bind(this);
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
this.handleResizeStart = this.handleResizeStart.bind(this);
this.setEditor = this.setEditor.bind(this);
this.shouldFocusMarkdown = this.shouldFocusMarkdown.bind(this);
// getDerivedStateFromProps equivalent for undo/redo. Run during render
// (not in an effect) so the new markdownSource is applied before the commit,
// avoiding a one-frame flash of the old content. React bails out of the
// intermediate render without committing it.
const isUndoRedo =
undoLength !== prevUndoLengthRef.current ||
redoLength !== prevRedoLengthRef.current;
if (isUndoRedo) {
setMarkdownSource(component.meta.code as string);
setHasError(false);
prevUndoLengthRef.current = undoLength;
prevRedoLengthRef.current = redoLength;
}
componentDidMount(): void {
this.props.logEvent(LOG_ACTIONS_RENDER_CHART, {
viz_type: 'markdown',
start_offset: this.renderStartTime,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - this.renderStartTime,
});
}
static getDerivedStateFromProps(
nextProps: MarkdownProps,
state: MarkdownState,
): MarkdownState | null {
const { hasError, editorMode, markdownSource, undoLength, redoLength } =
state;
const {
component: nextComponent,
undoLength: nextUndoLength,
redoLength: nextRedoLength,
} = nextProps;
// user click undo or redo ?
if (nextUndoLength !== undoLength || nextRedoLength !== redoLength) {
return {
...state,
undoLength: nextUndoLength,
redoLength: nextRedoLength,
markdownSource: nextComponent.meta.code as string,
hasError: false,
};
}
// Sync external code changes (not from undo/redo) while in preview mode.
useEffect(() => {
if (
!isUndoRedo &&
!hasError &&
editorMode === 'preview' &&
nextComponent.meta.code !== markdownSource
component.meta.code !== markdownSource
) {
return {
...state,
markdownSource: nextComponent.meta.code as string,
};
setMarkdownSource(component.meta.code as string);
}
}, [isUndoRedo, component.meta.code, hasError, editorMode, markdownSource]);
return state;
}
// componentDidMount equivalent: log render event
useEffect(() => {
logEvent(LOG_ACTIONS_RENDER_CHART, {
viz_type: 'markdown',
start_offset: renderStartTimeRef.current,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - renderStartTimeRef.current,
});
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
static getDerivedStateFromError(): { hasError: boolean } {
return {
hasError: true,
};
}
componentDidUpdate(prevProps: MarkdownProps): void {
// componentDidUpdate equivalent: resize editor when width changes
useEffect(() => {
if (
this.state.editor &&
(prevProps.component.meta.width !== this.props.component.meta.width ||
prevProps.columnWidth !== this.props.columnWidth)
editor &&
(prevComponentWidthRef.current !== component.meta.width ||
prevColumnWidthRef.current !== columnWidth)
) {
// Handle both Ace editor (resize method) and EditorHandle (no resize needed)
if (typeof this.state.editor.resize === 'function') {
this.state.editor.resize(true);
if (typeof editor.resize === 'function') {
editor.resize(true);
}
}
}
prevComponentWidthRef.current = component.meta.width;
prevColumnWidthRef.current = columnWidth;
}, [editor, component.meta.width, columnWidth]);
componentDidCatch(): void {
if (this.state.editor && this.state.editorMode === 'preview') {
this.props.addDangerToast(
t(
'This markdown component has an error. Please revert your recent changes.',
),
);
}
}
setEditor(editor: EditorInstance): void {
// EditorHandle or Ace editor instance
// For Ace: editor.getSession().setUseWrapMode(true)
// For EditorHandle: wrapEnabled is handled via options
if (editor?.getSession) {
editor.getSession!().setUseWrapMode(true);
}
this.setState({
editor,
});
}
handleChangeFocus(nextFocus: boolean | number): void {
const nextFocused = !!nextFocus;
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
this.setState(() => ({ isFocused: nextFocused }));
this.handleChangeEditorMode(nextEditMode);
}
handleChangeEditorMode(mode: 'edit' | 'preview'): void {
const nextState: MarkdownState = {
...this.state,
editorMode: mode,
};
if (mode === 'preview') {
this.updateMarkdownContent();
nextState.hasError = false;
}
this.setState(nextState);
}
updateMarkdownContent(): void {
const { updateComponents, component } = this.props;
if (component.meta.code !== this.state.markdownSource) {
const updateMarkdownContent = useCallback((): void => {
if (component.meta.code !== markdownSource) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
code: this.state.markdownSource,
code: markdownSource,
},
},
});
}
}
}, [component, markdownSource, updateComponents]);
handleMarkdownChange(nextValue: string): void {
this.setState({
markdownSource: nextValue,
});
}
handleDeleteComponent(): void {
const { deleteComponent, id, parentId } = this.props;
deleteComponent(id, parentId);
}
handleResizeStart(...args: Parameters<ResizeStartCallback>): void {
const { editorMode } = this.state;
const { editMode, onResizeStart } = this.props;
const isEditing = editorMode === 'edit';
onResizeStart(...args);
if (editMode && isEditing) {
this.updateMarkdownContent();
const setEditor = useCallback((editorInstance: EditorInstance): void => {
// EditorHandle or Ace editor instance
// For Ace: editor.getSession().setUseWrapMode(true)
// For EditorHandle: wrapEnabled is handled via options
if (editorInstance?.getSession) {
editorInstance.getSession!().setUseWrapMode(true);
}
}
setEditorState(editorInstance);
}, []);
shouldFocusMarkdown(
event: MouseEvent,
container: HTMLElement | null,
menuRef: HTMLElement | null,
): boolean {
if (container?.contains(event.target as Node)) return true;
if (menuRef?.contains(event.target as Node)) return true;
const handleChangeEditorMode = useCallback(
(mode: 'edit' | 'preview'): void => {
if (mode === 'preview') {
updateMarkdownContent();
setHasError(false);
}
setEditorMode(mode);
},
[updateMarkdownContent],
);
return false;
}
const handleChangeFocus = useCallback(
(nextFocus: boolean | number): void => {
const nextFocused = !!nextFocus;
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
setIsFocused(nextFocused);
handleChangeEditorMode(nextEditMode);
},
[handleChangeEditorMode],
);
renderEditMode(): JSX.Element {
return (
const handleMarkdownChange = useCallback((nextValue: string): void => {
setMarkdownSource(nextValue);
}, []);
const handleDeleteComponent = useCallback((): void => {
deleteComponent(id, parentId);
}, [deleteComponent, id, parentId]);
const handleResizeStart = useCallback(
(...args: Parameters<ResizeStartCallback>): void => {
const isEditing = editorMode === 'edit';
onResizeStart(...args);
if (editMode && isEditing) {
updateMarkdownContent();
}
},
[editorMode, editMode, onResizeStart, updateMarkdownContent],
);
const shouldFocusMarkdown = useCallback(
(
event: MouseEvent,
container: HTMLElement | null,
menuRef: HTMLElement | null,
): boolean => {
if (container?.contains(event.target as Node)) return true;
if (menuRef?.contains(event.target as Node)) return true;
return false;
},
[],
);
const handleRenderError = useCallback(
(_error: Error, _info: ErrorInfo): void => {
setHasError(true);
if (editorMode === 'preview') {
addDangerToast(
t(
'This markdown component has an error. Please revert your recent changes.',
),
);
}
},
[addDangerToast, editorMode],
);
const renderEditMode = useMemo(
() => (
<EditorHost
id={`markdown-editor-${this.props.id}`}
onChange={this.handleMarkdownChange}
id={`markdown-editor-${id}`}
onChange={handleMarkdownChange}
width="100%"
height="100%"
value={
// this allows "select all => delete" to give an empty editor
typeof this.state.markdownSource === 'string'
? this.state.markdownSource
typeof markdownSource === 'string'
? markdownSource
: MARKDOWN_PLACE_HOLDER
}
language="markdown"
@@ -336,126 +334,116 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
onReady={(handle: EditorInstance) => {
// The handle provides access to the underlying editor for resize
if (handle && typeof handle.focus === 'function') {
this.setEditor(handle);
setEditor(handle);
}
}}
data-test="editor"
/>
);
}
),
[id, markdownSource, handleMarkdownChange, setEditor],
);
renderPreviewMode(): JSX.Element {
const { hasError } = this.state;
return (
const renderPreviewMode = useMemo(
() => (
<SafeMarkdown
source={
hasError
? MARKDOWN_ERROR_MESSAGE
: this.state.markdownSource || MARKDOWN_PLACE_HOLDER
: markdownSource || MARKDOWN_PLACE_HOLDER
}
htmlSanitization={this.props.htmlSanitization}
htmlSchemaOverrides={this.props.htmlSchemaOverrides}
htmlSanitization={htmlSanitization}
htmlSchemaOverrides={htmlSchemaOverrides}
/>
);
}
),
[hasError, markdownSource, htmlSanitization, htmlSchemaOverrides],
);
render() {
const { isFocused, editorMode } = this.state;
// inherit the size of parent columns
const widthMultiple =
parentComponent.type === COLUMN_TYPE
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
: component.meta.width || GRID_MIN_COLUMN_COUNT;
const {
component,
parentComponent,
index,
depth,
availableColumnCount,
columnWidth,
onResize,
onResizeStop,
handleComponentDrop,
editMode,
} = this.props;
const isEditing = editorMode === 'edit';
// inherit the size of parent columns
const widthMultiple =
parentComponent.type === COLUMN_TYPE
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
: component.meta.width || GRID_MIN_COLUMN_COUNT;
const menuItems = useMemo(
() => [
<MarkdownModeDropdown
key={`${component.id}-mode`}
id={`${component.id}-mode`}
value={editorMode}
onChange={handleChangeEditorMode}
/>,
],
[component.id, editorMode, handleChangeEditorMode],
);
const isEditing = editorMode === 'edit';
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({ dragSourceRef }: DragChildProps) => (
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
shouldFocus={this.shouldFocusMarkdown}
menuItems={[
<MarkdownModeDropdown
key={`${component.id}-mode`}
id={`${component.id}-mode`}
value={this.state.editorMode}
onChange={this.handleChangeEditorMode}
/>,
]}
editMode={editMode}
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({ dragSourceRef }: DragChildProps) => (
<WithPopoverMenu
onChangeFocus={handleChangeFocus}
shouldFocus={shouldFocusMarkdown}
menuItems={menuItems}
editMode={editMode}
>
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
id={component.id}
>
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
<ResizableContainer
id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
>
<ResizableContainer
id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={this.handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
>
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<ErrorBoundary
key={hasError ? 'markdown-error' : 'markdown-ok'}
onError={handleRenderError}
showMessage={false}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
{editMode && isEditing
? this.renderEditMode()
: this.renderPreviewMode()}
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
)}
</Draggable>
);
}
{editMode && isEditing ? renderEditMode : renderPreviewMode}
</ErrorBoundary>
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
)}
</Draggable>
);
}
interface ReduxState {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { memo } from 'react';
import cx from 'classnames';
import { css, styled } from '@apache-superset/core/theme';
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
@@ -62,37 +62,37 @@ const NewComponentPlaceholder = styled.div`
`}
`;
export default class DraggableNewComponent extends PureComponent<DraggableNewComponentProps> {
static defaultProps = {
className: null,
IconComponent: undefined,
};
render() {
const { label, id, type, className, meta, IconComponent } = this.props;
return (
<DragDroppable
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={0}
depth={0}
editMode
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<NewComponent ref={dragSourceRef} data-test="new-component">
<NewComponentPlaceholder
className={cx('new-component-placeholder', className)}
>
{IconComponent && <IconComponent iconSize="xl" />}
</NewComponentPlaceholder>
{label}
</NewComponent>
)}
</DragDroppable>
);
}
function DraggableNewComponent({
label,
id,
type,
className,
meta,
IconComponent,
}: DraggableNewComponentProps) {
return (
<DragDroppable
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={0}
depth={0}
editMode
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<NewComponent ref={dragSourceRef} data-test="new-component">
<NewComponentPlaceholder
className={cx('new-component-placeholder', className)}
>
{IconComponent && <IconComponent iconSize="xl" />}
</NewComponentPlaceholder>
{label}
</NewComponent>
)}
</DragDroppable>
);
}
export default memo(DraggableNewComponent);

View File

@@ -16,11 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import cx from 'classnames';
import { t } from '@apache-superset/core/translation';
import { css, styled } from '@apache-superset/core/theme';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import PopoverDropdown, {
OptionProps,
@@ -90,18 +88,19 @@ function renderOption(option: OptionProps) {
);
}
export default class BackgroundStyleDropdown extends PureComponent<BackgroundStyleDropdownProps> {
render() {
const { id, value, onChange } = this.props;
return (
<PopoverDropdown
id={id}
options={backgroundStyleOptions}
value={value}
onChange={onChange}
renderButton={renderButton}
renderOption={renderOption}
/>
);
}
export default function BackgroundStyleDropdown({
id,
value,
onChange,
}: BackgroundStyleDropdownProps) {
return (
<PopoverDropdown
id={id}
options={backgroundStyleOptions}
value={value}
onChange={onChange}
renderButton={renderButton}
renderOption={renderOption}
/>
);
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable react/no-unused-state */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -17,15 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { RefObject, ReactNode, PureComponent } from 'react';
import { RefObject, ReactNode, useCallback, memo } from 'react';
import { styled } from '@apache-superset/core/theme';
import cx from 'classnames';
interface HoverMenuProps {
position: 'left' | 'top';
innerRef: RefObject<HTMLDivElement>;
children: ReactNode;
position?: 'left' | 'top';
innerRef?: RefObject<HTMLDivElement> | null;
children?: ReactNode;
onHover?: (data: { isHovered: boolean }) => void;
}
@@ -66,45 +65,41 @@ const HoverStyleOverrides = styled.div`
}
`;
export default class HoverMenu extends PureComponent<HoverMenuProps> {
static defaultProps = {
position: 'left',
innerRef: null,
children: null,
};
handleMouseEnter = () => {
const { onHover } = this.props;
function HoverMenu({
position = 'left',
innerRef = null,
children = null,
onHover,
}: HoverMenuProps) {
const handleMouseEnter = useCallback(() => {
if (onHover) {
onHover({ isHovered: true });
}
};
}, [onHover]);
handleMouseLeave = () => {
const { onHover } = this.props;
const handleMouseLeave = useCallback(() => {
if (onHover) {
onHover({ isHovered: false });
}
};
}, [onHover]);
render() {
const { innerRef, position, children } = this.props;
return (
<HoverStyleOverrides className="hover-menu-container">
<div
ref={innerRef}
className={cx(
'hover-menu',
position === 'left' && 'hover-menu--left',
position === 'top' && 'hover-menu--top',
)}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
data-test="hover-menu"
>
{children}
</div>
</HoverStyleOverrides>
);
}
return (
<HoverStyleOverrides className="hover-menu-container">
<div
ref={innerRef}
className={cx(
'hover-menu',
position === 'left' && 'hover-menu--left',
position === 'top' && 'hover-menu--top',
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
data-test="hover-menu"
>
{children}
</div>
</HoverStyleOverrides>
);
}
export default memo(HoverMenu);

View File

@@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { t } from '@apache-superset/core/translation';
import PopoverDropdown, {
OnChangeHandler,
} from '@superset-ui/core/components/PopoverDropdown';
@@ -40,18 +38,18 @@ const dropdownOptions = [
},
];
export default class MarkdownModeDropdown extends PureComponent<MarkdownModeDropdownProps> {
render() {
const { id, value, onChange } = this.props;
return (
<PopoverDropdown
data-test="markdown-mode-dropdown"
id={id}
options={dropdownOptions}
value={value}
onChange={onChange}
/>
);
}
export default function MarkdownModeDropdown({
id,
value,
onChange,
}: MarkdownModeDropdownProps) {
return (
<PopoverDropdown
data-test="markdown-mode-dropdown"
id={id}
options={dropdownOptions}
value={value}
onChange={onChange}
/>
);
}

View File

@@ -106,7 +106,9 @@ test('should unfocus when another component is clicked', async () => {
<WithPopoverMenu
{...props}
editMode
shouldFocus={(event, container) => container?.contains(event.target)}
shouldFocus={(event, container, _menuRef) =>
container?.contains(event.target) ?? false
}
onChangeFocus={onChangeFocusA}
>
<div id="child-a" />
@@ -117,7 +119,9 @@ test('should unfocus when another component is clicked', async () => {
<WithPopoverMenu
{...props}
editMode
shouldFocus={(event, container) => container?.contains(event.target)}
shouldFocus={(event, container, _menuRef) =>
container?.contains(event.target) ?? false
}
onChangeFocus={onChangeFocusB}
>
<div id="child-b" />

View File

@@ -16,7 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, CSSProperties, PureComponent } from 'react';
import {
ReactNode,
CSSProperties,
useCallback,
useEffect,
useRef,
useState,
memo,
} from 'react';
import cx from 'classnames';
import { addAlpha } from '@superset-ui/core';
import { css, styled } from '@apache-superset/core/theme';
@@ -26,26 +34,32 @@ type ShouldFocusContainer = HTMLDivElement & {
};
interface WithPopoverMenuProps {
children: ReactNode;
disableClick: boolean;
menuItems: ReactNode[];
onChangeFocus: (focus: boolean) => void;
isFocused: boolean;
// Event argument is left as "any" because of the clash. In defaultProps it seems
children?: ReactNode;
disableClick?: boolean;
menuItems?: ReactNode[];
onChangeFocus?: ((focus: boolean) => void) | null;
isFocused?: boolean;
// Event argument is left as "any" because of the clash. In props it seems
// like it should be React.FocusEvent<>, however from handleClick() we can also
// derive that type is EventListenerOrEventListenerObject.
shouldFocus: (
shouldFocus?: (
event: any,
container: ShouldFocusContainer,
container: ShouldFocusContainer | null,
menuRef: HTMLDivElement | null,
) => boolean;
editMode: boolean;
style: CSSProperties;
editMode?: boolean;
style?: CSSProperties | null;
}
interface WithPopoverMenuState {
isFocused: boolean;
}
const defaultShouldFocus = (
event: any,
container: ShouldFocusContainer | null,
menuRef: HTMLDivElement | null,
): boolean => {
if (container?.contains(event.target)) return true;
if (menuRef?.contains(event.target)) return true;
return false;
};
const WithPopoverMenuStyles = styled.div`
${({ theme }) => css`
@@ -104,151 +118,114 @@ const PopoverMenuStyles = styled.div`
`}
`;
export default class WithPopoverMenu extends PureComponent<
WithPopoverMenuProps,
WithPopoverMenuState
> {
container: ShouldFocusContainer;
function WithPopoverMenu({
children = null,
disableClick = false,
menuItems = [],
onChangeFocus = null,
isFocused: isFocusedProp = false,
shouldFocus: shouldFocusFunc = defaultShouldFocus,
editMode = false,
style = null,
}: WithPopoverMenuProps) {
const [isFocused, setIsFocused] = useState(isFocusedProp);
const containerRef = useRef<ShouldFocusContainer | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
// Tracks the native event that just triggered focus via the container's
// onClick so the document-level listener (registered once focused) can
// skip it. Without this, the same click bubbles to document after a
// re-render has detached its event.target, causing shouldFocus to return
// false and immediately undoing the focus.
const focusEventRef = useRef<Event | null>(null);
menuRef: HTMLDivElement | null;
const handleClick = useCallback(
(event: any) => {
if (!editMode) {
return;
}
focusEvent: Event | null;
const nativeEvent = event.nativeEvent || event;
if (focusEventRef.current === nativeEvent) {
focusEventRef.current = null;
return;
}
static defaultProps = {
children: null,
disableClick: false,
onChangeFocus: null,
menuItems: [],
isFocused: false,
shouldFocus: (
event: any,
container: ShouldFocusContainer,
menuRef: HTMLDivElement | null,
) => {
if (container?.contains(event.target)) return true;
if (menuRef?.contains(event.target)) return true;
return false;
const shouldFocusResult = shouldFocusFunc(
event,
containerRef.current,
menuRef.current,
);
if (shouldFocusResult === isFocused) return;
if (!disableClick && shouldFocusResult && !isFocused) {
focusEventRef.current = nativeEvent;
setIsFocused(true);
if (onChangeFocus) onChangeFocus(true);
} else if (!shouldFocusResult && isFocused) {
setIsFocused(false);
if (onChangeFocus) onChangeFocus(false);
}
},
style: null,
};
[editMode, shouldFocusFunc, isFocused, disableClick, onChangeFocus],
);
constructor(props: WithPopoverMenuProps) {
super(props);
this.state = {
isFocused: props.isFocused!,
};
this.menuRef = null;
this.focusEvent = null;
this.setRef = this.setRef.bind(this);
this.setMenuRef = this.setMenuRef.bind(this);
this.handleClick = this.handleClick.bind(this);
}
// Keep the latest handleClick in a ref so the document listeners can be
// registered via a stable wrapper. This keeps the listener effect dependent
// only on focus/editMode transitions, instead of thrashing (remove + re-add)
// every time handleClick's identity changes.
const handleClickRef = useRef(handleClick);
useEffect(() => {
handleClickRef.current = handleClick;
}, [handleClick]);
componentDidUpdate(prevProps: WithPopoverMenuProps) {
if (this.props.editMode && this.props.isFocused && !this.state.isFocused) {
document.addEventListener('click', this.handleClick);
document.addEventListener('drag', this.handleClick);
this.setState({ isFocused: true });
} else if (this.state.isFocused && !this.props.editMode) {
document.removeEventListener('click', this.handleClick);
document.removeEventListener('drag', this.handleClick);
this.setState({ isFocused: false });
// Handle prop-driven focus changes and add/remove document listeners
useEffect(() => {
if (editMode && isFocusedProp && !isFocused) {
setIsFocused(true);
} else if (isFocused && !editMode) {
setIsFocused(false);
}
}
}, [editMode, isFocusedProp, isFocused]);
componentWillUnmount() {
document.removeEventListener('click', this.handleClick);
document.removeEventListener('drag', this.handleClick);
}
// Add/remove document event listeners only on focus/editMode transitions.
useEffect(() => {
if (isFocused && editMode) {
const listener = (event: Event) => handleClickRef.current(event);
document.addEventListener('click', listener);
document.addEventListener('drag', listener);
setRef(ref: ShouldFocusContainer) {
this.container = ref;
}
setMenuRef(ref: HTMLDivElement | null) {
this.menuRef = ref;
}
shouldHandleFocusChange(shouldFocus: boolean): boolean {
const { disableClick } = this.props;
const { isFocused } = this.state;
return (
(!disableClick && shouldFocus && !isFocused) ||
(!shouldFocus && isFocused)
);
}
handleClick(event: any) {
if (!this.props.editMode) {
return;
return () => {
document.removeEventListener('click', listener);
document.removeEventListener('drag', listener);
};
}
return undefined;
}, [isFocused, editMode]);
// Skip if this is the same event that just triggered focus via onClick.
// The document-level listener registered during focus will see the same
// event bubble up; by that time a re-render may have detached the
// original event.target, causing shouldFocus to return false and
// immediately undoing the focus.
const nativeEvent = event.nativeEvent || event;
if (this.focusEvent === nativeEvent) {
this.focusEvent = null;
return;
}
const {
onChangeFocus,
shouldFocus: shouldFocusFunc,
disableClick,
} = this.props;
const shouldFocus = shouldFocusFunc(event, this.container, this.menuRef);
if (shouldFocus === this.state.isFocused) return;
if (!disableClick && shouldFocus && !this.state.isFocused) {
document.addEventListener('click', this.handleClick);
document.addEventListener('drag', this.handleClick);
this.focusEvent = event.nativeEvent || event;
this.setState(() => ({ isFocused: true }));
if (onChangeFocus) onChangeFocus(true);
} else if (!shouldFocus && this.state.isFocused) {
document.removeEventListener('click', this.handleClick);
document.removeEventListener('drag', this.handleClick);
this.setState(() => ({ isFocused: false }));
if (onChangeFocus) onChangeFocus(false);
}
}
render() {
const { children, menuItems, editMode, style } = this.props;
const { isFocused } = this.state;
return (
<WithPopoverMenuStyles
ref={this.setRef}
onClick={this.handleClick}
role="none"
className={cx(
'with-popover-menu',
editMode && isFocused && 'with-popover-menu--focused',
)}
style={style}
>
{children}
{editMode && isFocused && (menuItems?.length ?? 0) > 0 && (
<PopoverMenuStyles ref={this.setMenuRef}>
{menuItems.map((node: ReactNode, i: number) => (
<div className="menu-item" key={`menu-item-${i}`}>
{node}
</div>
))}
</PopoverMenuStyles>
)}
</WithPopoverMenuStyles>
);
}
return (
<WithPopoverMenuStyles
ref={containerRef}
onClick={handleClick}
role="none"
className={cx(
'with-popover-menu',
editMode && isFocused && 'with-popover-menu--focused',
)}
style={style ?? undefined}
>
{children}
{editMode && isFocused && menuItems?.some(Boolean) && (
<PopoverMenuStyles ref={menuRef}>
{menuItems.map((node: ReactNode, i: number) => (
<div className="menu-item" key={`menu-item-${i}`}>
{node}
</div>
))}
</PopoverMenuStyles>
)}
</WithPopoverMenuStyles>
);
}
export default memo(WithPopoverMenu);

View File

@@ -1,148 +0,0 @@
/**
* 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 { ReactNode, useState, useCallback } from 'react';
import type { FormInstance } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { BaseModalBody, BaseForm, BaseModalWrapper } from './SharedStyles';
import { ModalFooter } from './ModalFooter';
export interface BaseConfigModalProps {
isOpen: boolean;
title: string;
expanded?: boolean;
onCancel: () => void;
onSave: () => void;
leftPane: ReactNode;
rightPane: ReactNode;
footer?: ReactNode;
form?: FormInstance;
onValuesChange?: (changedValues: any, allValues: any) => void;
canSave?: boolean;
saveAlertVisible?: boolean;
onDismissSaveAlert?: () => void;
onConfirmCancel?: () => void;
onToggleExpand?: () => void;
testId?: string;
maskClosable?: boolean;
destroyOnClose?: boolean;
centered?: boolean;
}
export const BaseConfigModal = ({
isOpen,
title,
expanded = false,
onCancel,
onSave,
leftPane,
rightPane,
footer,
form,
onValuesChange,
canSave = true,
saveAlertVisible = false,
onDismissSaveAlert,
onConfirmCancel,
onToggleExpand,
testId = 'base-config-modal',
maskClosable = false,
destroyOnClose = true,
centered = true,
}: BaseConfigModalProps) => {
const [internalExpanded, setInternalExpanded] = useState(false);
const isExpandedControlled = onToggleExpand !== undefined;
const isExpanded = isExpandedControlled ? expanded : internalExpanded;
const handleToggleExpand = useCallback(() => {
if (isExpandedControlled && onToggleExpand) {
onToggleExpand();
} else {
setInternalExpanded(!internalExpanded);
}
}, [isExpandedControlled, onToggleExpand, internalExpanded]);
const handleCancel = useCallback(() => {
onCancel();
}, [onCancel]);
const handleSave = useCallback(() => {
onSave();
}, [onSave]);
const handleDismissSaveAlert = useCallback(() => {
if (onDismissSaveAlert) {
onDismissSaveAlert();
}
}, [onDismissSaveAlert]);
const handleConfirmCancel = useCallback(() => {
if (onConfirmCancel) {
onConfirmCancel();
} else {
onCancel();
}
}, [onConfirmCancel, onCancel]);
const defaultFooter = (
<ModalFooter
onCancel={handleCancel}
onSave={handleSave}
onConfirmCancel={handleConfirmCancel}
onDismiss={handleDismissSaveAlert}
saveAlertVisible={saveAlertVisible}
canSave={canSave}
expanded={isExpanded}
onToggleExpand={handleToggleExpand}
saveButtonTestId={`${testId}-save-button`}
cancelButtonTestId={`${testId}-cancel-button`}
/>
);
return (
<BaseModalWrapper
open={isOpen}
onCancel={handleCancel}
onOk={handleSave}
title={title}
footer={footer || defaultFooter}
centered={centered}
destroyOnClose={destroyOnClose}
maskClosable={maskClosable}
data-test={testId}
expanded={isExpanded}
>
<ErrorBoundary>
<BaseModalBody expanded={isExpanded}>
<BaseForm
form={form}
onValuesChange={onValuesChange}
layout="vertical"
css={{ width: '100%' }}
>
{leftPane}
{rightPane}
</BaseForm>
</BaseModalBody>
</ErrorBoundary>
</BaseModalWrapper>
);
};
export default BaseConfigModal;

View File

@@ -0,0 +1,228 @@
/**
* 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 { render, screen, waitFor } from 'spec/helpers/testing-library';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { NativeFilterType } from '@superset-ui/core';
import type { Filter } from '@superset-ui/core';
import FilterValue from './FilterValue';
const mockGetChartDataRequest = jest.fn();
jest.mock('src/components/Chart/chartAction', () => ({
getChartDataRequest: (...args: unknown[]) => mockGetChartDataRequest(...args),
}));
jest.mock('src/middleware/asyncEvent', () => ({
waitForAsyncData: jest.fn(),
}));
jest.mock('@superset-ui/core', () => {
const original = jest.requireActual('@superset-ui/core');
return {
...original,
getChartMetadataRegistry: () => ({
get: () => ({ enableNoResults: false }),
}),
SuperChart: (props: Record<string, unknown>) => (
<div data-test="mock-super-chart" data-chart-type={props.chartType}>
SuperChart
</div>
),
isFeatureEnabled: () => false,
getClientErrorObject: (_err: unknown) =>
Promise.resolve({
message: 'Something went wrong',
errors: [
{ message: 'Test error', error_type: 'GENERIC_BACKEND_ERROR' },
],
}),
};
});
jest.mock('../useFilterOutlined', () => ({
useFilterOutlined: () => ({
outlinedFilterId: undefined,
lastUpdated: 0,
}),
}));
const mockUseFilterDependencies = jest.fn().mockReturnValue({});
const mockUseTransitiveParentIds = jest.fn().mockReturnValue([]);
jest.mock('./state', () => ({
useFilterDependencies: (...args: unknown[]) =>
mockUseFilterDependencies(...args),
useTransitiveParentIds: (...args: unknown[]) =>
mockUseTransitiveParentIds(...args),
}));
const mockStore = configureStore([thunk]);
const createMockFilter = (overrides: Partial<Filter> = {}): Filter => ({
id: 'NATIVE_FILTER-1',
name: 'Test Filter',
filterType: 'filter_select',
targets: [{ datasetId: 1, column: { name: 'country' } }],
defaultDataMask: {},
controlValues: {},
cascadeParentIds: [],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
type: NativeFilterType.NativeFilter,
description: 'Test filter description',
...overrides,
});
const getDefaultStoreState = () => ({
dashboardInfo: { id: 1 },
dashboardState: {
isRefreshing: false,
isFiltersRefreshing: false,
directPathToChild: [],
directPathLastUpdated: 0,
},
nativeFilters: {
filters: {
'NATIVE_FILTER-1': createMockFilter(),
},
filterSets: {},
},
dataMask: {},
charts: {},
dashboardLayout: { present: {} },
});
const defaultProps = {
filter: createMockFilter(),
dataMaskSelected: {},
onFilterSelectionChange: jest.fn(),
inView: true,
};
function renderFilterValue(
propOverrides: Record<string, unknown> = {},
stateOverrides: Record<string, unknown> = {},
) {
const state = { ...getDefaultStoreState(), ...stateOverrides };
const store = mockStore(state);
const mergedProps = { ...defaultProps, ...propOverrides };
return render(
<Provider store={store}>
<FilterValue {...(mergedProps as typeof defaultProps)} />
</Provider>,
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('renders loading spinner when filter has a data source', () => {
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
renderFilterValue();
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.queryByTestId('mock-super-chart')).not.toBeInTheDocument();
});
test('renders SuperChart after data loads successfully', async () => {
mockGetChartDataRequest.mockResolvedValue({
response: { status: 200 },
json: { result: [{ data: [{ country: 'US' }] }] },
});
renderFilterValue();
await waitFor(() => {
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
});
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
test('renders error state when API call fails', async () => {
mockGetChartDataRequest.mockRejectedValue(
new Response(JSON.stringify({ message: 'Server Error' }), { status: 500 }),
);
renderFilterValue();
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
// No ErrorMessageComponent is registered for GENERIC_BACKEND_ERROR in the
// test environment, so FilterValue renders its fallback ErrorAlert.
expect(await screen.findByText('Network error')).toBeInTheDocument();
});
test('does not fetch data when filter has not been in view', () => {
renderFilterValue({ inView: false });
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
});
test('does not render loading spinner when filter has no data source', () => {
const filterWithoutDataSource = createMockFilter({
targets: [{ column: { name: 'country' } }],
});
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
renderFilterValue({ filter: filterWithoutDataSource });
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
});
test('skips data fetch when cascade parent filters have no values selected', () => {
// useFilterDependencies returns dependencies with a filter (from parent defaults),
// but dataMaskSelected has no extraFormData for the parent -- counts disagree, so
// the component skips the fetch.
mockUseFilterDependencies.mockReturnValue({
filters: [{ col: 'region', op: 'IN', val: ['US'] }],
});
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
const childFilter = createMockFilter({
id: 'NATIVE_FILTER-CHILD',
cascadeParentIds: ['NATIVE_FILTER-PARENT'],
});
const stateWithParent = {
nativeFilters: {
filters: {
'NATIVE_FILTER-CHILD': childFilter,
'NATIVE_FILTER-PARENT': createMockFilter({
id: 'NATIVE_FILTER-PARENT',
}),
},
filterSets: {},
},
};
renderFilterValue(
{
filter: childFilter,
dataMaskSelected: {},
},
stateWithParent,
);
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
});

View File

@@ -41,7 +41,8 @@ import {
getClientErrorObject,
isChartCustomization,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { styled, SupersetTheme } from '@apache-superset/core/theme';
import { useTheme } from '@emotion/react';
import { useDispatch, useSelector } from 'react-redux';
import { isEqual, isEqualWith } from 'lodash';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
@@ -141,6 +142,7 @@ const FilterValue: FC<FilterValueProps> = ({
clearAllTrigger,
onClearAllComplete,
}) => {
const theme = useTheme() as SupersetTheme;
const { id, targets, filterType } = filter;
const isCustomization = isChartCustomization(filter);
const allowedTimeGrains = isCustomization
@@ -487,6 +489,7 @@ const FilterValue: FC<FilterValueProps> = ({
enableNoResults={metadata?.enableNoResults}
isRefreshing={isRefreshing}
hooks={hooks}
theme={theme}
/>
)}
</StyledDiv>

View File

@@ -299,7 +299,10 @@ test('checkIsApplyDisabled returns true when required filter is missing value in
);
});
test('checkIsApplyDisabled handles filter count mismatch', () => {
test('checkIsApplyDisabled enables Apply when Selected has a filter value not yet in Applied', () => {
// Regression: when a required filter's default isn't applied (Applied missing
// the entry) and the user types a value, Selected gains an entry Applied
// doesn't have. Apply must be enabled so the user can commit the value.
const dataMaskSelected: DataMaskStateWithId = {
'filter-1': {
id: 'filter-1',
@@ -322,7 +325,7 @@ test('checkIsApplyDisabled handles filter count mismatch', () => {
const filters = [createFilter('filter-1'), createFilter('filter-2')];
expect(checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters)).toBe(
true,
false,
);
});

View File

@@ -74,13 +74,9 @@ export const checkIsApplyDisabled = (
const selectedExtraFormData = getOnlyExtraFormData(dataMaskSelected);
const appliedExtraFormData = getOnlyExtraFormData(dataMaskApplied);
// Check counts first
const selectedCount = Object.keys(selectedExtraFormData).length;
const appliedCount = Object.keys(appliedExtraFormData).length;
if (selectedCount !== appliedCount) return true;
// Check for changes
// Check for changes. ignoreUndefined drops empty keys on both sides so that
// a filter present in Selected with a real value but absent (or undefined)
// in Applied is correctly detected as a change.
const dataEqual = areObjectsEqual(
selectedExtraFormData,
appliedExtraFormData,

View File

@@ -1,29 +0,0 @@
/**
* 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 { useSelector } from 'react-redux';
import { useMemo } from 'react';
import { RootState } from 'src/dashboard/types';
import getChartIdsFromLayout from '../getChartIdsFromLayout';
export const useAllChartIds = () => {
const layout = useSelector(
(state: RootState) => state.dashboardLayout.present,
);
return useMemo(() => getChartIdsFromLayout(layout), [layout]);
};

View File

@@ -1,113 +0,0 @@
/**
* 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 { Layout, LayoutItem } from 'src/dashboard/types';
import { TAB_TYPE, DASHBOARD_GRID_TYPE } from '../componentTypes';
import { DASHBOARD_ROOT_ID } from '../constants';
import findNonTabChildChartIds from './findNonTabChildChartIds';
interface TopLevelNode {
id: string;
type: string;
parent_type: string | null;
parent_id: string | null;
index: number | null;
depth: number;
slice_ids: number[];
}
interface RecurseParams {
node: LayoutItem | undefined;
index?: number | null;
depth: number;
parentType?: string | null;
parentId?: string | null;
}
// This function traverses the layout to identify top grid + tab level components
// for which we track load times
function findTopLevelComponentIds(layout: Layout): TopLevelNode[] {
const topLevelNodes: TopLevelNode[] = [];
function recurseFromNode({
node,
index = null,
depth,
parentType = null,
parentId = null,
}: RecurseParams): void {
if (!node) return;
let nextParentType = parentType;
let nextParentId = parentId;
let nextDepth = depth;
if (node.type === TAB_TYPE || node.type === DASHBOARD_GRID_TYPE) {
const chartIds = findNonTabChildChartIds({
layout,
id: node.id,
});
topLevelNodes.push({
id: node.id,
type: node.type,
parent_type: parentType,
parent_id: parentId,
index,
depth,
slice_ids: chartIds,
});
nextParentId = node.id;
nextParentType = node.type;
nextDepth += 1;
}
if (node.children && node.children.length) {
node.children.forEach((childId, childIndex) => {
recurseFromNode({
node: layout[childId],
index: childIndex,
parentType: nextParentType,
parentId: nextParentId,
depth: nextDepth,
});
});
}
}
recurseFromNode({
node: layout[DASHBOARD_ROOT_ID],
depth: 0,
});
return topLevelNodes;
}
// This method is called frequently, so cache results
let cachedLayout: Layout | undefined;
let cachedTopLevelNodes: TopLevelNode[] = [];
export default function findTopLevelComponentIdsWithCache(
layout: Layout,
): TopLevelNode[] {
if (layout === cachedLayout) {
return cachedTopLevelNodes;
}
cachedLayout = layout;
cachedTopLevelNodes = findTopLevelComponentIds(layout);
return cachedTopLevelNodes;
}

View File

@@ -1,59 +0,0 @@
/**
* 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 { ChartState } from 'src/explore/types';
import { Layout } from 'src/dashboard/types';
import findTopLevelComponentIds from './findTopLevelComponentIds';
import childChartsDidLoad from './childChartsDidLoad';
interface GetLoadStatsParams {
layout: Layout;
chartQueries: Record<string, Partial<ChartState>>;
}
interface LoadStats {
didLoad: boolean;
id: string;
minQueryStartTime: number | null;
[key: string]: unknown;
}
export default function getLoadStatsPerTopLevelComponent({
layout,
chartQueries,
}: GetLoadStatsParams): Record<string, LoadStats> {
const topLevelComponents = findTopLevelComponentIds(layout);
const stats: Record<string, LoadStats> = {};
topLevelComponents.forEach(topLevelComponent => {
const { id, ...restStats } = topLevelComponent;
const { didLoad, minQueryStartTime } = childChartsDidLoad({
id,
layout,
chartQueries,
});
stats[id] = {
didLoad,
id,
minQueryStartTime,
...restStats,
};
});
return stats;
}

View File

@@ -42,7 +42,9 @@ const isUserDashboardOwner = (
user: UserWithPermissionsAndRoles | UndefinedUser,
) =>
isUserWithPermissionsAndRoles(user) &&
dashboard.owners.some(owner => owner.id === user.userId);
[...dashboard.owners, ...(dashboard.extra_owners ?? [])].some(
owner => owner.id === user.userId,
);
export const canUserEditDashboard = (
dashboard: Dashboard,

View File

@@ -1,42 +0,0 @@
/**
* 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.
*/
export const stopPeriodicRender = (refreshTimer?: number) => {
if (refreshTimer) {
clearInterval(refreshTimer);
}
};
interface SetPeriodicRunnerProps {
interval?: number;
periodicRender: TimerHandler;
refreshTimer?: number;
}
export default function setPeriodicRunner({
interval = 0,
periodicRender,
refreshTimer,
}: SetPeriodicRunnerProps) {
stopPeriodicRender(refreshTimer);
if (interval > 0) {
return setInterval(periodicRender, interval);
}
return 0;
}

View File

@@ -121,6 +121,38 @@ test('when user edits a filter without changing targets, their selection is pres
);
});
test('when a required range filter was cleared to [null, null], modifying it applies the new default instead of the cleared state', () => {
// Regression for the PR #40470 review: [null, null] is a range filter's
// canonical "cleared" value. It must count as "no value" so the empty state
// does not wipe a newly-defined default — consistent with `loadedHasValue`
// in fillNativeFilters.
const initialState: DataMaskStateWithId = {
'NATIVE_FILTER-1': {
id: 'NATIVE_FILTER-1',
...getInitialDataMask('NATIVE_FILTER-1'),
filterState: { value: [null, null] },
},
};
const oldFilters = {
'NATIVE_FILTER-1': createFilter('NATIVE_FILTER-1', 'col_a', {
enableEmptyFilter: true,
}),
};
const modifiedFilter: Filter = {
...createFilter('NATIVE_FILTER-1', 'col_a', { enableEmptyFilter: true }),
defaultDataMask: { filterState: { value: [10, 20] } },
};
const action = createModifyAction(modifiedFilter, oldFilters);
const result = reducer(initialState, action);
// The cleared [null, null] state must not be preserved; the new default wins.
expect(result['NATIVE_FILTER-1']?.filterState?.value).toEqual([10, 20]);
});
// Runtime data from the server can contain null entries in
// chart_customization_config even though the TS type does not include | null
// yet. These helpers build HYDRATE_DASHBOARD actions that mirror that reality.

View File

@@ -109,10 +109,50 @@ function fillNativeFilters(
) {
filterConfig.forEach((filter: Filter) => {
const dataMask = initialDataMask || {};
const loaded = dataMask[filter.id];
// The shallow spread of `loaded` would override `filter.defaultDataMask`
// even when the loaded mask is incomplete (e.g. a permalink captured
// mid-initialization), wiping out a valid default. For REQUIRED filters
// with a default, fall back to the default when the loaded mask carries
// no real value OR is missing the extraFormData needed to filter charts.
// Non-required filters keep current behavior so a user's explicit clear
// isn't undone.
const isRequired = !!filter.controlValues?.enableEmptyFilter;
const loadedValue = loaded?.filterState?.value;
const loadedHasValue =
loadedValue !== undefined &&
loadedValue !== null &&
!(
// Treat all-null arrays (range filters use [null, null] as their
// canonical cleared value) and empty arrays as "no value".
(Array.isArray(loadedValue) && loadedValue.every(v => v === null))
);
const loadedHasExtraFormData =
!!loaded?.extraFormData && Object.keys(loaded.extraFormData).length > 0;
const defaultHasExtraFormData =
!!filter.defaultDataMask?.extraFormData &&
Object.keys(filter.defaultDataMask.extraFormData).length > 0;
// Restore when:
// (1) loaded value is empty — classic "default wiped by stale permalink", OR
// (2) loaded has a value but no extraFormData and the default does — the
// "value present in UI but not applied to charts" gap-window case where
// a permalink was captured before FilterValue produced extraFormData.
const shouldRestoreDefault =
isRequired &&
!!filter.defaultDataMask &&
(!loadedHasValue || (!loadedHasExtraFormData && defaultHasExtraFormData));
mergedDataMask[filter.id] = {
...getInitialDataMask(filter.id), // take initial data
...filter.defaultDataMask, // if something new came from BE - take it
...dataMask[filter.id],
...loaded,
...(shouldRestoreDefault
? {
filterState: filter.defaultDataMask?.filterState,
extraFormData: filter.defaultDataMask?.extraFormData,
}
: {}),
};
if (
currentFilters &&
@@ -155,12 +195,28 @@ function updateDataMaskForFilterChanges(
// Check if targets are equal
const areTargetsEqual = isEqual(prevFilterDef?.targets, filter?.targets);
// Preserve state only if filter exists, has enableEmptyFilter=true and targets match
// For required filters, only preserve existing state when it actually has
// a value — otherwise the empty existing state would wipe the (possibly
// newly-defined) default. defaultToFirstItem filters keep the old behavior:
// FilterValue re-resolves the first item at runtime, so preserving the
// mid-init empty state is fine.
const isRequired = !!filter.controlValues?.enableEmptyFilter;
const isFirstItem = !!filter.controlValues?.defaultToFirstItem;
const existingValue = existingFilter?.filterState?.value;
const hasExistingValue =
existingValue !== undefined &&
existingValue !== null &&
// Treat all-null arrays (range filters use [null, null] as their
// canonical cleared value) and empty arrays as "no value", consistent
// with `loadedHasValue` in fillNativeFilters above. `[].every()` is
// true, so this also covers the empty-array case.
!(Array.isArray(existingValue) && existingValue.every(v => v === null));
const shouldPreserveState =
existingFilter &&
areTargetsEqual &&
(filter.controlValues?.enableEmptyFilter ||
filter.controlValues?.defaultToFirstItem);
(isRequired || isFirstItem) &&
(isFirstItem || hasExistingValue);
mergedDataMask[filter.id] = {
...getInitialDataMask(filter.id),

View File

@@ -177,9 +177,11 @@ export const hydrateExplore =
can_copy_clipboard: granularExport
? findPermission('can_copy_clipboard', 'Superset', user?.roles)
: findPermission('can_csv', 'Superset', user?.roles),
can_overwrite: ensureIsArray(slice?.owners).includes(
user?.userId as number,
),
can_overwrite:
ensureIsArray(slice?.owners).includes(user?.userId as number) ||
ensureIsArray(metadata?.extra_owners).some(
(o: { id: number }) => o.id === user?.userId,
),
isDatasourceMetaLoading: false,
isStarred: false,
triggerRender: false,

View File

@@ -75,6 +75,7 @@ interface SaveModalProps extends RouteComponentProps {
alert?: string;
sliceName?: string;
slice?: Record<string, any>;
can_overwrite?: boolean;
datasource?: Record<string, any>;
dashboardId: '' | number | null;
isVisible: boolean;
@@ -128,7 +129,8 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
canOverwriteSlice(): boolean {
return (
(isUserAdmin(this.props.user) ||
(this.props.can_overwrite ||
isUserAdmin(this.props.user) ||
this.props.slice?.owners?.includes(this.props.user.userId)) &&
!this.props.slice?.is_managed_externally
);
@@ -819,6 +821,7 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
interface StateProps {
datasource: any;
slice: any;
can_overwrite: boolean;
user: UserWithPermissionsAndRoles;
dashboards: any;
alert: any;
@@ -833,6 +836,7 @@ function mapStateToProps({
return {
datasource: explore.datasource,
slice: explore.slice,
can_overwrite: explore.can_overwrite,
user,
dashboards: saveModal.dashboards,
alert: saveModal.saveModalAlert,

View File

@@ -45,6 +45,7 @@ import TextControl from 'src/explore/components/controls/TextControl';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
import PopoverSection from '@superset-ui/core/components/PopoverSection';
import ControlHeader from 'src/explore/components/ControlHeader';
import { ensureAppRoot } from 'src/utils/pathUtils';
import {
ANNOTATION_SOURCE_TYPES,
ANNOTATION_TYPES,
@@ -145,7 +146,7 @@ const NotFoundContent = () => (
<span>
{t('Add an annotation layer')}{' '}
<a
href="/annotationlayer/list"
href={ensureAppRoot('/annotationlayer/list')}
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -88,6 +88,7 @@ export interface ExplorePageInitialData {
created_on_humanized: string;
changed_on_humanized: string;
owners: string[];
extra_owners?: { id: number; first_name: string; last_name: string }[];
created_by?: string;
changed_by?: string;
color_namespace?: string;

View File

@@ -31,7 +31,6 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
version: '1.0.0',
dependencies: [],
remoteEntry: '',
extensionDependencies: [],
...overrides,
};
}

View File

@@ -72,6 +72,7 @@ afterEach(() => {
test('renders without crashing', () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -95,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => {
// Verify the global superset object is set up
expect((window as any).superset).toBeDefined();
expect((window as any).superset.authentication).toBeDefined();
expect((window as any).superset.chat).toBeDefined();
expect((window as any).superset.core).toBeDefined();
expect((window as any).superset.commands).toBeDefined();
expect((window as any).superset.extensions).toBeDefined();
@@ -109,6 +112,7 @@ test('sets up global superset object when user is logged in', async () => {
test('does not set up global superset object when user is not logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -127,6 +131,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -144,6 +149,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
test('does not initialize ExtensionsLoader when user is not logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -169,6 +175,7 @@ test('only initializes once even with multiple renders', async () => {
const { rerender } = render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -205,6 +212,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -234,6 +242,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -268,6 +277,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
</ExtensionsStartup>,
{
useRedux: true,
useRouter: true,
initialState: mockInitialState,
},
);

View File

@@ -16,58 +16,78 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { logging } from '@apache-superset/core/utils';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
// eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import {
authentication,
chat,
core,
commands,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
} from 'src/core';
import { notifyPageChange } from 'src/core/navigation';
import { useSelector } from 'react-redux';
import { RootState } from 'src/views/store';
import ExtensionsLoader from './ExtensionsLoader';
declare global {
interface Window {
superset: {
authentication: typeof authentication;
core: typeof core;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
sqlLab: typeof sqlLab;
views: typeof views;
};
}
}
import 'src/extensions/Namespaces';
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
const location = useLocation();
const prevPathname = useRef<string | null>(null);
const userId = useSelector<RootState, number | undefined>(
({ user }) => user.userId,
);
// Notify the navigation namespace on every route change.
useEffect(() => {
if (userId == null) return;
if (prevPathname.current !== location.pathname) {
prevPathname.current = location.pathname;
notifyPageChange(location.pathname);
}
}, [location.pathname]);
// Provide the implementations for @apache-superset/core
// Log unhandled rejections that may originate from extension code.
// Registered once for the lifetime of the app; does not suppress the
// browser's default error surfacing so host error reporting is unaffected.
useEffect(() => {
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
logging.error('[extensions] Unhandled rejection:', event.reason);
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener(
'unhandledrejection',
handleUnhandledRejection,
);
};
}, []);
useEffect(() => {
// Provide the implementations for @apache-superset/core.
// Namespaces are listed explicitly — do not spread the core package here,
// as that would leak un-contracted symbols onto window.superset.
window.superset = {
...supersetCore,
authentication,
chat,
core,
commands,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
};

View File

@@ -0,0 +1,60 @@
/**
* 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.
*/
/**
* Global `window.superset` type augmentation.
*
* Lives in its own module (rather than inline in ExtensionsStartup) so every
* file that reads or writes `window.superset` — notably ExtensionsLoader —
* sees the type regardless of how files are batched during compilation. Both
* the startup component and the loader import this module for its side effect.
*/
import type {
authentication,
chat,
commands,
core,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
} from 'src/core';
/** The host namespaces exposed to extensions on `window.superset`. */
export interface Namespaces {
authentication: typeof authentication;
core: typeof core;
chat: typeof chat;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
navigation: typeof navigation;
sqlLab: typeof sqlLab;
views: typeof views;
}
declare global {
interface Window {
superset: Namespaces;
}
}

View File

@@ -437,6 +437,32 @@ describe('UploadDataModal - Form Validation', () => {
expect(screen.getByText('Table name is required')).toBeInTheDocument();
});
});
test('database validation error clears after selecting a database', async () => {
render(<UploadDataModal {...csvProps} />, { useRedux: true });
const uploadButton = screen.getByRole('button', { name: 'Upload' });
await userEvent.click(uploadButton);
await waitFor(() => {
expect(
screen.getByText('Selecting a database is required'),
).toBeInTheDocument();
});
const selectDatabase = screen.getByRole('combobox', {
name: /select a database/i,
});
await userEvent.click(selectDatabase);
await waitFor(() => screen.getByText('database1'));
await userEvent.click(screen.getByText('database1'));
await waitFor(() => {
expect(
screen.queryByText('Selecting a database is required'),
).not.toBeInTheDocument();
});
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks

View File

@@ -578,8 +578,11 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
return Promise.resolve();
};
const validateDatabase = (_: any, value: string) => {
if (!currentDatabaseId) {
const validateDatabase = (
_: any,
value: { value: number; label: string } | null | undefined,
) => {
if (!value?.value) {
return Promise.reject(t('Selecting a database is required'));
}
return Promise.resolve();

View File

@@ -29,8 +29,8 @@ import {
DatasetObject,
} from 'src/features/datasets/AddDataset/types';
import { Table } from 'src/hooks/apiResources';
import { Typography } from '@superset-ui/core/components/Typography';
import { ensureAppRoot } from 'src/utils/pathUtils';
import { Typography } from '@superset-ui/core/components/Typography';
interface LeftPanelProps {
setDataset: Dispatch<SetStateAction<object>>;

View File

@@ -218,10 +218,10 @@ export function Menu({
const path = location.pathname;
switch (true) {
case path.startsWith(Paths.Dashboard):
setActiveTabs(['Dashboards']);
setActiveTabs([t('Dashboards')]);
break;
case path.startsWith(Paths.Chart) || path.startsWith(Paths.Explore):
setActiveTabs(['Charts']);
setActiveTabs([t('Charts')]);
break;
case path.startsWith(Paths.Datasets):
setActiveTabs([datasetsLabel()]);
@@ -263,9 +263,10 @@ export function Menu({
const childItems: MenuItem[] = [];
childs?.forEach((child: MenuObjectChildProps | string, index1: number) => {
if (typeof child === 'string' && child === '-' && label !== 'Data') {
if (typeof child === 'string' && child === '-' && label !== t('Data')) {
childItems.push({ type: 'divider', key: `divider-${index1}` });
} else if (typeof child !== 'string') {
Object.assign(child, { label: t(child.label) });
childItems.push({
key: `${child.label}`,
label: child.isFrontendRoute ? (
@@ -366,6 +367,7 @@ export function Menu({
items={menu.map(item => {
const props = {
...item,
label: t(item.label),
isFrontendRoute: isFrontendRoute(item.url),
childs: item.childs?.map(c => {
if (typeof c === 'string') {
@@ -429,15 +431,16 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) {
// Apply any label override for this item (keyed by FAB internal name).
...(item.name && labelOverrides[item.name]
? { label: labelOverrides[item.name]() }
: {}),
: { label: t(item.label) }),
};
// Filter childs
if (item.childs) {
item.childs.forEach((child: MenuObjectChildProps | string) => {
if (typeof child === 'string') {
children.push(child);
children.push(t(child));
} else if ((child as MenuObjectChildProps).label) {
Object.assign(child, { label: t(child.label) });
children.push(child);
}
});

View File

@@ -1,126 +0,0 @@
/**
* 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 { Link } from 'react-router-dom';
import { t } from '@apache-superset/core/translation';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import { CardStyles } from 'src/views/CRUD/utils';
import {
Button,
Dropdown,
ConfirmStatusChange,
ListViewCard,
} from '@superset-ui/core/components';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { Icons } from '@superset-ui/core/components/Icons';
import { Tag } from 'src/views/CRUD/types';
import { deleteTags } from 'src/features/tags/tags';
import { assetUrl } from 'src/utils/assetUrl';
interface TagCardProps {
tag: Tag;
hasPerm: (name: string) => boolean;
bulkSelectEnabled: boolean;
refreshData: () => void;
loading: boolean;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
tagFilter?: string;
userId?: string | number;
showThumbnails?: boolean;
}
function TagCard({
tag,
hasPerm,
refreshData,
addDangerToast,
addSuccessToast,
showThumbnails,
}: TagCardProps) {
const canDelete = hasPerm('can_write');
const handleTagDelete = (tag: Tag) => {
deleteTags([tag], addSuccessToast, addDangerToast);
refreshData();
};
const menuItems: MenuItem[] = [];
if (canDelete) {
menuItems.push({
key: 'delete-tag',
label: (
<ConfirmStatusChange
title={t('Please confirm')}
description={
<>
{t('Are you sure you want to delete')} <b>{tag.name}</b>?
</>
}
onConfirm={() => handleTagDelete(tag)}
>
{confirmDelete => (
<div
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
data-test="dashboard-card-option-delete-button"
>
<Icons.DeleteOutlined iconSize="l" /> {t('Delete')}
</div>
)}
</ConfirmStatusChange>
),
});
}
return (
<CardStyles>
<ListViewCard
title={tag.name}
cover={
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
<></>
) : null
}
url={undefined}
linkComponent={Link}
imgFallbackURL={assetUrl(
'/static/assets/images/dashboard-card-fallback.svg',
)}
description={t('Modified %s', tag.changed_on_delta_humanized)}
actions={
<ListViewCard.Actions
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
<Dropdown menu={{ items: menuItems }} trigger={['click', 'hover']}>
<Button buttonSize="xsmall" buttonStyle="link">
<Icons.MoreOutlined iconSize="xl" />
</Button>
</Dropdown>
</ListViewCard.Actions>
}
/>
</CardStyles>
);
}
export default TagCard;

View File

@@ -1,39 +0,0 @@
/**
* 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 { useEffect, useRef } from 'react';
import { isDefined } from '@superset-ui/core';
export const useMemoCompare = <T>(
next: T,
compare: (prev: T | undefined, next: T) => boolean,
) => {
const previousRef = useRef<T>();
const previous = previousRef.current;
const isEqual = compare(previous, next);
useEffect(() => {
if (!isEqual) {
previousRef.current = next;
}
});
if (!isDefined(previous)) {
return next;
}
return isEqual ? previous : next;
};

View File

@@ -1,32 +0,0 @@
/**
* 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 { useEffect, useRef } from 'react';
export function useOpenerRef(active: boolean) {
const openerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (active) {
openerRef.current = document.activeElement as HTMLElement;
}
}, [active]);
return openerRef;
}

View File

@@ -34,6 +34,7 @@ export interface Dashboard {
changed_on: string;
charts: string[]; // just chart names, unfortunately...
owners: Owner[];
extra_owners?: Owner[];
roles: Role[];
theme?: {
id: number;

View File

@@ -110,6 +110,34 @@ describe('getBootstrapData and helpers', () => {
expect(staticAssetsPrefix()).toEqual(expectedStaticPrefix);
});
test.each([
['//evil.example.com', 'protocol-relative URL'],
// eslint-disable-next-line no-script-url -- intentional unsafe value under test
['javascript:alert(1)', 'javascript scheme'],
['https://evil.example.com', 'absolute URL'],
['/foo"><img src=x>', 'path with HTML meta-characters'],
])(
'should fall back to the default root when application_root is %s (%s)',
async unsafeRoot => {
const customData = {
common: {
application_root: unsafeRoot,
static_assets_prefix: '/custom-static/',
},
};
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(customData)}'></div>`;
jest.resetModules();
const { default: getBootstrapData, applicationRoot } =
await import('./getBootstrapData');
getBootstrapData();
const expectedAppRoot =
DEFAULT_BOOTSTRAP_DATA.common.application_root.replace(/\/$/, '');
expect(applicationRoot()).toEqual(expectedAppRoot);
},
);
test('should defaults without trailing slashes when #app element does not include application_root or static_assets_prefix', async () => {
// Set up the fake #app element
const customData = {

View File

@@ -38,7 +38,34 @@ const normalizePathWithFallback = (
fallback: string,
): string => (path ?? fallback).replace(/\/$/, '');
const APPLICATION_ROOT_NO_TRAILING_SLASH = normalizePathWithFallback(
/**
* Matches a plain absolute path prefix (e.g. "" for root deployments or
* "/analytics" for a subdirectory). The character after the leading slash must
* not be another slash, so protocol-relative URLs ("//host") and scheme-bearing
* values ("javascript:...") do not qualify.
*/
const SAFE_APPLICATION_ROOT_RE = /^(\/[\w\-.][\w\-./]*)?$/;
/**
* The application root (SUPERSET_APP_ROOT) is reflected into links and
* navigation, so constrain it to a plain absolute path before use. Anything
* that isn't a simple "/path" prefix falls back to the default root so a
* malformed value can't be reinterpreted as HTML or redirect off-origin. This
* also keeps the bootstrap-derived value from being treated as a tainted href
* source by static analysis.
*/
const sanitizeApplicationRoot = (
path: string | undefined,
fallback: string,
): string => {
const normalizedFallback = normalizePathWithFallback(fallback, fallback);
const normalized = normalizePathWithFallback(path, fallback);
return SAFE_APPLICATION_ROOT_RE.test(normalized)
? normalized
: normalizedFallback;
};
const APPLICATION_ROOT_NO_TRAILING_SLASH = sanitizeApplicationRoot(
getBootstrapData().common.application_root,
DEFAULT_BOOTSTRAP_DATA.common.application_root,
);

View File

@@ -1,30 +0,0 @@
/**
* 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.
*/
export enum DashboardVirtualizationMode {
None = 'NONE',
Viewport = 'VIEWPORT',
Paginated = 'PAGINATED',
}
export const isDashboardVirtualizationEnabled = (
virtualizationMode: DashboardVirtualizationMode,
) =>
virtualizationMode === DashboardVirtualizationMode.Viewport ||
virtualizationMode === DashboardVirtualizationMode.Paginated;

View File

@@ -38,7 +38,9 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils';
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
import { logEvent } from 'src/logger/actions';
import { store } from 'src/views/store';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
import ChatMount from 'src/components/ChatMount';
import { RootContextProviders } from './RootContextProviders';
import { ScrollToTop } from './ScrollToTop';
@@ -112,6 +114,13 @@ const App = () => (
</Route>
))}
</Switch>
{/*
The singleton chat slot. Rendered as a sibling of the route
Switch — inside ExtensionsStartup so chat extensions have been
loaded and registered, but outside the Switch so the chat persists
across route changes.
*/}
{isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatMount />}
</ExtensionsStartup>
<ToastContainer />
</RootContextProviders>

View File

@@ -10,7 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"cookie": "^1.1.1",
"hot-shots": "^15.0.0",
"hot-shots": "^16.0.0",
"ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3",
"lodash": "^4.18.1",
@@ -26,7 +26,7 @@
"@types/node": "^25.9.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
@@ -37,7 +37,7 @@
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1"
"typescript-eslint": "^8.61.0"
},
"engines": {
"node": "^24.16.0",
@@ -1844,17 +1844,17 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
"integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/type-utils": "8.60.1",
"@typescript-eslint/utils": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/type-utils": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -1867,7 +1867,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.60.1",
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -1883,16 +1883,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3"
},
"engines": {
@@ -1908,14 +1908,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
"integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.60.1",
"@typescript-eslint/types": "^8.60.1",
"@typescript-eslint/tsconfig-utils": "^8.61.0",
"@typescript-eslint/types": "^8.61.0",
"debug": "^4.4.3"
},
"engines": {
@@ -1930,14 +1930,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz",
"integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1"
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1948,9 +1948,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz",
"integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1965,15 +1965,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz",
"integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/utils": "8.60.1",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -1990,9 +1990,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
"integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2004,16 +2004,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
"integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.60.1",
"@typescript-eslint/tsconfig-utils": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"@typescript-eslint/project-service": "8.61.0",
"@typescript-eslint/tsconfig-utils": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -2071,16 +2071,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz",
"integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1"
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2095,13 +2095,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
"integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/types": "8.61.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -3465,9 +3465,9 @@
}
},
"node_modules/hot-shots": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-15.0.0.tgz",
"integrity": "sha512-89EmKbvjVbDdFmUcvMl1x9XaKdEzg1VDLElbKaQCPC88wrus6O5XlCyZ+KbwZk9Dy4BNcsyfEHMfSkUtRZHBQg==",
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-16.0.0.tgz",
"integrity": "sha512-1WHuq7vv0Hj6wiSmR89XpxxiNnw9s1W50yGJExC3/PSqVv+Kr7GSk3rz0jsTWjhIkF1c5Nz9mpLdzJ+CqKKwMg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -6188,16 +6188,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz",
"integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz",
"integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.60.1",
"@typescript-eslint/parser": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/utils": "8.60.1"
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7927,16 +7927,16 @@
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
"integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
"dev": true,
"requires": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/type-utils": "8.60.1",
"@typescript-eslint/utils": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/type-utils": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -7951,75 +7951,75 @@
}
},
"@typescript-eslint/parser": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3"
}
},
"@typescript-eslint/project-service": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
"integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
"dev": true,
"requires": {
"@typescript-eslint/tsconfig-utils": "^8.60.1",
"@typescript-eslint/types": "^8.60.1",
"@typescript-eslint/tsconfig-utils": "^8.61.0",
"@typescript-eslint/types": "^8.61.0",
"debug": "^4.4.3"
}
},
"@typescript-eslint/scope-manager": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz",
"integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1"
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0"
}
},
"@typescript-eslint/tsconfig-utils": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz",
"integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
"dev": true,
"requires": {}
},
"@typescript-eslint/type-utils": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz",
"integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/utils": "8.60.1",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
}
},
"@typescript-eslint/types": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
"integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
"integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
"dev": true,
"requires": {
"@typescript-eslint/project-service": "8.60.1",
"@typescript-eslint/tsconfig-utils": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"@typescript-eslint/project-service": "8.61.0",
"@typescript-eslint/tsconfig-utils": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -8054,24 +8054,24 @@
}
},
"@typescript-eslint/utils": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz",
"integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1"
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0"
}
},
"@typescript-eslint/visitor-keys": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
"integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/types": "8.61.0",
"eslint-visitor-keys": "^5.0.0"
},
"dependencies": {
@@ -9020,9 +9020,9 @@
}
},
"hot-shots": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-15.0.0.tgz",
"integrity": "sha512-89EmKbvjVbDdFmUcvMl1x9XaKdEzg1VDLElbKaQCPC88wrus6O5XlCyZ+KbwZk9Dy4BNcsyfEHMfSkUtRZHBQg==",
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-16.0.0.tgz",
"integrity": "sha512-1WHuq7vv0Hj6wiSmR89XpxxiNnw9s1W50yGJExC3/PSqVv+Kr7GSk3rz0jsTWjhIkF1c5Nz9mpLdzJ+CqKKwMg==",
"requires": {
"unix-dgram": "2.x"
}
@@ -11021,15 +11021,15 @@
"dev": true
},
"typescript-eslint": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz",
"integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz",
"integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==",
"dev": true,
"requires": {
"@typescript-eslint/eslint-plugin": "8.60.1",
"@typescript-eslint/parser": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/utils": "8.60.1"
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.0"
}
},
"uglify-js": {

View File

@@ -18,7 +18,7 @@
"license": "Apache-2.0",
"dependencies": {
"cookie": "^1.1.1",
"hot-shots": "^15.0.0",
"hot-shots": "^16.0.0",
"ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3",
"lodash": "^4.18.1",
@@ -34,7 +34,7 @@
"@types/node": "^25.9.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
@@ -45,7 +45,7 @@
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1"
"typescript-eslint": "^8.61.0"
},
"engines": {
"node": "^24.16.0",

View File

@@ -21,7 +21,7 @@ from io import BytesIO
from typing import Any, cast, Optional
from zipfile import is_zipfile, ZipFile
from flask import redirect, request, Response, send_file, url_for
from flask import current_app, redirect, request, Response, send_file, url_for
from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
from flask_appbuilder.hooks import before_request
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -310,6 +310,8 @@ class ChartRestApi(BaseSupersetModelRestApi):
try:
dash = ChartDAO.get_by_id_or_uuid(id_or_uuid)
result = self.chart_get_response_schema.dump(dash)
if resolver := current_app.config.get("EXTRA_OWNERS_RESOLVER"):
result["extra_owners"] = resolver(dash)
return self.response(200, result=result)
except ChartNotFoundError:
return self.response_404()

View File

@@ -16,6 +16,7 @@
# under the License.
from typing import Any
from flask import current_app
from flask_babel import lazy_gettext as _
from sqlalchemy import and_, or_
from sqlalchemy.orm import aliased
@@ -109,7 +110,22 @@ class ChartFilter(BaseFilter): # pylint: disable=too-few-public-methods
query = query.join(
models.Database, table_alias.database_id == models.Database.id
)
return query.filter(get_dataset_access_filters(self.model))
extra_access_filters = []
extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
if extra_charts_filter := extra_filters.get("charts"):
user_id = get_user_id()
if user_id:
extra_access_filters.append(
self.model.id.in_(extra_charts_filter(user_id))
)
return query.filter(
or_(
get_dataset_access_filters(self.model),
*extra_access_filters,
)
)
class ChartHasCreatedByFilter(BaseFilter): # pylint: disable=too-few-public-methods

View File

@@ -1454,6 +1454,21 @@ class ChartDataQueryObjectSchema(Schema):
allow_none=True,
)
@post_load
def rename_deprecated_fields(
self, data: dict[str, Any], **kwargs: Any
) -> dict[str, Any]:
_renames = (
("groupby", "columns"),
("granularity_sqla", "granularity"),
("timeseries_limit", "series_limit"),
("timeseries_limit_metric", "series_limit_metric"),
)
for old, new in _renames:
if value := data.pop(old, None):
data[new] = value
return data
class ChartDataQueryContextSchema(Schema):
query_context_factory: QueryContextFactory | None = None

View File

@@ -39,14 +39,14 @@ logger = logging.getLogger(__name__)
@click.command()
@with_appcontext
@transaction()
@click.option("--database_name", "-d", help="Database name to change")
@click.option("--uri", "-u", help="Database URI to change")
@click.option("--database_name", "-d", required=True, help="Database name to change")
@click.option("--uri", "-u", required=True, help="Database URI to change")
@click.option(
"--skip_create",
"-s",
is_flag=True,
default=False,
help="Create the DB if it doesn't exist",
help="Don't create the DB if it doesn't exist",
)
def set_database_uri(database_name: str, uri: str, skip_create: bool) -> None:
"""Updates a database connection URI"""

View File

@@ -19,7 +19,7 @@ from datetime import datetime
from functools import partial
from typing import Any, Optional
from flask import g
from flask import current_app, g
from flask_appbuilder.models.sqla import Model
from marshmallow import ValidationError
@@ -58,7 +58,10 @@ class CreateChartCommand(CreateMixin, BaseCommand):
self.validate()
self._properties["last_saved_at"] = datetime.now()
self._properties["last_saved_by"] = g.user
return ChartDAO.create(attributes=self._properties)
chart = ChartDAO.create(attributes=self._properties)
if after_create := current_app.config.get("AFTER_ASSET_CREATE"):
after_create(chart, "chart")
return chart
def validate(self) -> None:
exceptions = []

View File

@@ -78,7 +78,7 @@ class ExportChartsCommand(ExportModelsCommand):
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
tags = getattr(model, "tags", [])
payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom]
file_content = yaml.safe_dump(payload, sort_keys=False)
file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
return file_content
_include_tags: bool = True # Default to True

Some files were not shown because too many files have changed in this diff Show More