mirror of
https://github.com/apache/superset.git
synced 2026-06-10 10:09:14 +00:00
Compare commits
92 Commits
semantic-l
...
enxdev/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c92f1f5ae | ||
|
|
5294d05467 | ||
|
|
bf71eb6712 | ||
|
|
6a07ad2369 | ||
|
|
793ffb3d80 | ||
|
|
f575fdae3a | ||
|
|
7b418becc7 | ||
|
|
ba7db15f02 | ||
|
|
b0d26196fc | ||
|
|
df8222ffcd | ||
|
|
64f0e88de7 | ||
|
|
f4af6a2caf | ||
|
|
31087177ab | ||
|
|
8e98ca6569 | ||
|
|
f7f6c29adf | ||
|
|
a94edfe418 | ||
|
|
5549100601 | ||
|
|
558ff4452b | ||
|
|
5966bb1c1e | ||
|
|
ac035083d7 | ||
|
|
e25d708197 | ||
|
|
48cb3f5885 | ||
|
|
dcef6f8a41 | ||
|
|
f09fd63495 | ||
|
|
bc26006a43 | ||
|
|
8b483f320e | ||
|
|
89c2a47433 | ||
|
|
b8b91574e0 | ||
|
|
5526464def | ||
|
|
73f66e4c14 | ||
|
|
f187a8e1c4 | ||
|
|
4c3f65ef0b | ||
|
|
53d8e5bdfa | ||
|
|
2f95d288dd | ||
|
|
2f5fcc21f9 | ||
|
|
d1d07112aa | ||
|
|
e3711bec39 | ||
|
|
ce9cab098f | ||
|
|
a183582291 | ||
|
|
3acef94ef6 | ||
|
|
9638eecdb1 | ||
|
|
7e74fc4192 | ||
|
|
cdca6f7fdc | ||
|
|
b1ca8cac6b | ||
|
|
2cd5efa627 | ||
|
|
a273fe4d62 | ||
|
|
d203f0de33 | ||
|
|
a75f9b67b2 | ||
|
|
3f0858e35d | ||
|
|
68c145adc3 | ||
|
|
4a9aecda4a | ||
|
|
46b2d7d7a9 | ||
|
|
f8600471fa | ||
|
|
b23c65e04f | ||
|
|
aa8255c55c | ||
|
|
10b7bfc8c1 | ||
|
|
89cab1860e | ||
|
|
b7585122c8 | ||
|
|
f2d80a183e | ||
|
|
69adecd6a3 | ||
|
|
fbffae0444 | ||
|
|
6ce7c2e8de | ||
|
|
105820f1f4 | ||
|
|
92b1b0a219 | ||
|
|
c39a47cbac | ||
|
|
dacda71f77 | ||
|
|
12a21c8933 | ||
|
|
13fa3810a8 | ||
|
|
3356f4d3e1 | ||
|
|
4a17c49d74 | ||
|
|
ea1ce7140c | ||
|
|
038414ea5c | ||
|
|
5bb54cc96b | ||
|
|
fb276b08dd | ||
|
|
6e8b3bf976 | ||
|
|
55024e8f4d | ||
|
|
b98bd2a07a | ||
|
|
0a3a35018c | ||
|
|
e6179036ec | ||
|
|
81b4d580db | ||
|
|
9acfac1523 | ||
|
|
aa9af6c307 | ||
|
|
fbb3056508 | ||
|
|
ffbce27c9b | ||
|
|
fe8b218a5f | ||
|
|
f5fe9bfa26 | ||
|
|
7f1c47521e | ||
|
|
0fffa74bc6 | ||
|
|
738ebf9cc6 | ||
|
|
98dff2e170 | ||
|
|
b5ad4a7a07 | ||
|
|
c85661f4fd |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -38,7 +38,7 @@
|
||||
|
||||
# Notify translation maintainers of changes to translations
|
||||
|
||||
/superset/translations/ @sfirke
|
||||
/superset/translations/ @sfirke @rusackas
|
||||
|
||||
# Notify PMC members of changes to extension-related files
|
||||
|
||||
|
||||
16
.github/SECURITY.md
vendored
16
.github/SECURITY.md
vendored
@@ -33,13 +33,21 @@ We kindly ask you to include the following information in your report to assist
|
||||
- Expected vs. Actual Behavior: A clear description of the intended system behavior versus the observed vulnerability.
|
||||
- Detailed Reproduction Steps: Clear, manual steps to reproduce the vulnerability.
|
||||
|
||||
**Vulnerability Definition**
|
||||
|
||||
Apache Superset considers a security vulnerability to be a demonstrable issue that has meaningful impact on confidentiality, integrity, or availability beyond the intended security model. Low-impact boundary variations or technical edge cases in existing access controls may be classified as hardening improvements rather than vulnerabilities, even if exploitable.
|
||||
|
||||
**Out of Scope Vulnerabilities**
|
||||
|
||||
To prioritize engineering efforts on genuine architectural risks, the following scenarios are explicitly out of scope and will not be issued a CVE:
|
||||
- Attacks requiring Admin privileges: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
|
||||
- Brute Force and Rate Limiting: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
|
||||
- Theoretical attack vectors: Issues without a demonstrable, reproducible exploit path.
|
||||
- Non-Exploitable Findings: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
|
||||
- **Attacks requiring Admin privileges**: (e.g., CSS injection, template manipulation, dashboard ownership overrides, or modifying global system settings). Per the CVE vulnerability definition in CNA Operational Rules 4.1, a qualifying vulnerability must allow violation of a security policy. The Admin role is a fully trusted operational boundary defined by Apache Superset's security policy; actions within this boundary do not violate that policy and are therefore considered intended capabilities 'by design,' not vulnerabilities.
|
||||
- **Brute Force and Rate Limiting**: Reports targeting a lack of resource exhaustion protections, generic rate-limiting, or volumetric Denial of Service (DoS) attempts.
|
||||
- **Theoretical attack vectors**: Issues without a demonstrable, reproducible exploit path.
|
||||
- **Non-Exploitable Findings**: Missing security headers, generic banner disclosures, or descriptive error messages that do not lead to a direct, documented exploit.
|
||||
- **User enumeration**: API responses, timing differences, or error messages that reveal whether user accounts, IDs, dashboards, or datasets exist.
|
||||
- **Information disclosure (low impact)**: Software version disclosure, generic error messages, stack traces without sensitive data exposure, or system configuration details that don't enable further exploitation.
|
||||
- **Resource exhaustion requiring authentication**: Denial of Service attacks that require valid user credentials and don't bypass rate limiting or resource controls.
|
||||
- **Missing security headers**: Without demonstration of a concrete exploit scenario that leverages the missing header.
|
||||
|
||||
**Outcome of Reports**
|
||||
|
||||
|
||||
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@@ -62,6 +62,11 @@ updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
open-pull-requests-limit: 10
|
||||
# Bump the lower bound to the new version, not just widen the upper
|
||||
# bound. Without this, a `sqlglot>=28.10.0, <29` constraint upgraded
|
||||
# to `<30` would keep the stale lower bound forever, dragging
|
||||
# transitively-resolved versions with it. See #40186 (review thread).
|
||||
versioning-strategy: increase
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: steps.check.outputs.superset-extensions-cli
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: superset-extensions-cli
|
||||
|
||||
2
.github/workflows/superset-frontend.yml
vendored
2
.github/workflows/superset-frontend.yml
vendored
@@ -128,7 +128,7 @@ jobs:
|
||||
run: npx nyc merge coverage/ merged-output/coverage-summary.json
|
||||
|
||||
- name: Upload Code Coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: javascript
|
||||
use_oidc: true
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,mysql
|
||||
verbose: true
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,postgres
|
||||
verbose: true
|
||||
@@ -219,7 +219,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,sqlite
|
||||
verbose: true
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,presto
|
||||
verbose: true
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
pip install -e .[hive]
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,hive
|
||||
verbose: true
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,unit
|
||||
verbose: true
|
||||
|
||||
@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=all
|
||||
enable=json-import,disallowed-sql-import,consider-using-transaction
|
||||
enable=disallowed-sql-import,consider-using-transaction
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
@@ -202,6 +202,8 @@ RUN mkdir -p /app/data && chown -R superset:superset /app/data
|
||||
|
||||
# Copy compiled things from previous stages
|
||||
COPY --from=superset-node /app/superset/static/assets superset/static/assets
|
||||
# Copy service.worker.js optionall as it doesn't exist when DEV_MODE=true
|
||||
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
|
||||
|
||||
# TODO, when the next version comes out, use --exclude superset/translations
|
||||
COPY superset superset
|
||||
|
||||
@@ -36,9 +36,9 @@ Screenshots will be taken but no messages actually sent as long as `ALERT_REPORT
|
||||
#### In your `Dockerfile`
|
||||
|
||||
You'll need to extend the Superset image to include a headless browser. Your options include:
|
||||
- Use Playwright with Chrome: this is the recommended approach as of version 4.1.x or greater. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/admin-docs/installation/docker-builds#building-your-own-production-docker-image) page. Read the code comments there as you'll also need to change a feature flag in your config.
|
||||
- Use Firefox: you'll need to install geckodriver and Firefox.
|
||||
- Use Chrome without Playwright: you'll need to install Chrome and set the value of `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
|
||||
- Use Playwright with Chromium: this is the recommended approach as of version 4.1.x or greater. Playwright always uses Chromium — the `WEBDRIVER_TYPE` config setting has no effect when Playwright is active. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/admin-docs/installation/docker-builds#building-your-own-production-docker-image) page. Enable the `PLAYWRIGHT_REPORTS_AND_THUMBNAILS` feature flag in your config to activate it.
|
||||
- Use Firefox (Selenium): you'll need to install geckodriver and Firefox. Set `WEBDRIVER_TYPE` to `"firefox"` in your `superset_config.py`.
|
||||
- Use Chrome (Selenium): you'll need to install Chrome. Set `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
|
||||
|
||||
In Superset versions <=4.0x, users installed Firefox or Chrome and that was documented here.
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.33",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.30",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
@@ -109,8 +109,8 @@
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"webpack": "^5.106.2"
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"webpack": "^5.107.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -206,12 +206,26 @@ async function downloadBadge(url, staticDir) {
|
||||
badgeCache.set(url, webPath);
|
||||
return webPath;
|
||||
} catch (error) {
|
||||
// Fail the build on badge download failure
|
||||
throw new Error(
|
||||
`[remark-localize-badges] Failed to download badge: ${url}\n` +
|
||||
`Error: ${error.message}\n` +
|
||||
`Build cannot continue with broken badges. Please fix the badge URL or remove it.`,
|
||||
// Soft fallback: keep the original remote URL in the rendered output
|
||||
// so the badge still appears for readers, and the docs build continues.
|
||||
// External badge services (notably img.shields.io) rate-limit CI IPs
|
||||
// aggressively, and a transient fetch failure shouldn't take the whole
|
||||
// docs build down with it. Set REMARK_BADGES_STRICT=true to opt back
|
||||
// into hard-fail-the-build behavior (e.g. for release builds where you
|
||||
// want to catch genuinely broken badge URLs).
|
||||
if (process.env.REMARK_BADGES_STRICT === 'true') {
|
||||
throw new Error(
|
||||
`[remark-localize-badges] Failed to download badge: ${url}\n` +
|
||||
`Error: ${error.message}\n` +
|
||||
`Build cannot continue with broken badges (REMARK_BADGES_STRICT=true).`,
|
||||
);
|
||||
}
|
||||
console.warn(
|
||||
`[remark-localize-badges] Could not localize ${url} ` +
|
||||
`(${error.message}); falling back to remote URL.`,
|
||||
);
|
||||
badgeCache.set(url, url);
|
||||
return url;
|
||||
} finally {
|
||||
// Clean up the in-flight tracker
|
||||
inFlightDownloads.delete(url);
|
||||
|
||||
237
docs/yarn.lock
237
docs/yarn.lock
@@ -4455,22 +4455,6 @@
|
||||
dependencies:
|
||||
"@types/ms" "*"
|
||||
|
||||
"@types/eslint-scope@^3.7.7":
|
||||
version "3.7.7"
|
||||
resolved "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz"
|
||||
integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==
|
||||
dependencies:
|
||||
"@types/eslint" "*"
|
||||
"@types/estree" "*"
|
||||
|
||||
"@types/eslint@*":
|
||||
version "9.6.1"
|
||||
resolved "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz"
|
||||
integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==
|
||||
dependencies:
|
||||
"@types/estree" "*"
|
||||
"@types/json-schema" "*"
|
||||
|
||||
"@types/estree-jsx@^1.0.0":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz"
|
||||
@@ -4612,7 +4596,7 @@
|
||||
resolved "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz"
|
||||
integrity sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==
|
||||
|
||||
"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
|
||||
"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
@@ -4828,100 +4812,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.59.3", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz#5d6da7e7b236b46452fa00d3904bb6f59615bfde"
|
||||
integrity sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==
|
||||
"@typescript-eslint/eslint-plugin@8.59.4", "@typescript-eslint/eslint-plugin@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz#c67bfee32caae9cb587dce1ac59c3bf43b659707"
|
||||
integrity sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/type-utils" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/type-utils" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.59.3", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.3.tgz#f46cbc70ae0a25119ef94eac9ecd46714788e1a1"
|
||||
integrity sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==
|
||||
"@typescript-eslint/parser@8.59.4", "@typescript-eslint/parser@^8.59.3":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.4.tgz#77d99e3b27663e7a22cf12c3fb769db509e5e93c"
|
||||
integrity sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.3.tgz#1be5ae152aad987a156c9a1a9b4256e75cfbbe0c"
|
||||
integrity sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==
|
||||
"@typescript-eslint/project-service@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.4.tgz#5830535a0e7a3ae806e2669964f47a74c4bc6b0e"
|
||||
integrity sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.3"
|
||||
"@typescript-eslint/types" "^8.59.3"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.4"
|
||||
"@typescript-eslint/types" "^8.59.4"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz#91a60f66803fe9dae0696fbab2451f5723f119d2"
|
||||
integrity sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==
|
||||
"@typescript-eslint/scope-manager@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz#507d1258c758147dac1adee9517a205a8ac1e046"
|
||||
integrity sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.59.3", "@typescript-eslint/tsconfig-utils@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz#88ca9036b42ccdd1e630cfdafd2e042c2ca6a835"
|
||||
integrity sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==
|
||||
"@typescript-eslint/tsconfig-utils@8.59.4", "@typescript-eslint/tsconfig-utils@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz#218ba229d96dde35212e3a76a7d0a6bc831398be"
|
||||
integrity sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==
|
||||
|
||||
"@typescript-eslint/type-utils@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz#421fb2448bdfeb301d134a01cd02503f67fd8192"
|
||||
integrity sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==
|
||||
"@typescript-eslint/type-utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz#359fc53ba39a1f1860fddda40ebe5bfe0d87faed"
|
||||
integrity sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.59.3", "@typescript-eslint/types@^8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.3.tgz#b7ca539c5e302fdde9a7cadb73caed107ef8f2cd"
|
||||
integrity sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==
|
||||
"@typescript-eslint/types@8.59.4", "@typescript-eslint/types@^8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.4.tgz#c29d5c21bfbaa8347ddc677d3ac1fcd2db0f848e"
|
||||
integrity sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz#e6bb1408e00b47e431427a40268db4e86cb121ab"
|
||||
integrity sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==
|
||||
"@typescript-eslint/typescript-estree@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz#d005e5e1fb425526f39685594bed34a04ad755ea"
|
||||
integrity sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.59.3"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/visitor-keys" "8.59.3"
|
||||
"@typescript-eslint/project-service" "8.59.4"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/visitor-keys" "8.59.4"
|
||||
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.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.3.tgz#f693f979deb4dc3994de03ff8b23976d625c36c5"
|
||||
integrity sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==
|
||||
"@typescript-eslint/utils@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.4.tgz#8ccd2b08aecc72c7efc0d7ac6695631d199d256e"
|
||||
integrity sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/scope-manager" "8.59.4"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.59.3":
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz#820843b1b5ca4290009cf189382abcf6fe00dfa6"
|
||||
integrity sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==
|
||||
"@typescript-eslint/visitor-keys@8.59.4":
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz#1ac23b747b011f5cbdb449da97769f6c5f3a9355"
|
||||
integrity sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.3"
|
||||
"@typescript-eslint/types" "8.59.4"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -5584,10 +5568,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.30, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.30"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz#58915c74388b05f3b3504026194ea9fa98f6e6b6"
|
||||
integrity sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==
|
||||
baseline-browser-mapping@^2.10.31, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.31"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz#9c6825f052601ce6974a90dd49683b1726887b0b"
|
||||
integrity sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -7269,13 +7253,13 @@ encodeurl@~2.0.0:
|
||||
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
|
||||
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
|
||||
|
||||
enhanced-resolve@^5.20.0:
|
||||
version "5.20.0"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz#323c2a70d2aa7fb4bdfd6d3c24dfc705c581295d"
|
||||
integrity sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==
|
||||
enhanced-resolve@^5.21.4:
|
||||
version "5.21.5"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz#8f80167d009d8f01267ad61035e59fe5c94ac3a6"
|
||||
integrity sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.3.0"
|
||||
tapable "^2.3.3"
|
||||
|
||||
entities@^2.0.0:
|
||||
version "2.2.0"
|
||||
@@ -7391,10 +7375,10 @@ es-iterator-helpers@^1.2.1:
|
||||
iterator.prototype "^1.1.4"
|
||||
safe-array-concat "^1.1.3"
|
||||
|
||||
es-module-lexer@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz"
|
||||
integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==
|
||||
es-module-lexer@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c"
|
||||
integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==
|
||||
|
||||
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||
version "1.1.1"
|
||||
@@ -9655,10 +9639,10 @@ liquid-json@0.3.1:
|
||||
resolved "https://registry.npmjs.org/liquid-json/-/liquid-json-0.3.1.tgz"
|
||||
integrity sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ==
|
||||
|
||||
loader-runner@^4.3.1:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz"
|
||||
integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==
|
||||
loader-runner@^4.3.2:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.2.tgz#9913d3a15971f8f635915e601fb5c9d495d918e9"
|
||||
integrity sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==
|
||||
|
||||
loader-utils@^1.2.3:
|
||||
version "1.4.2"
|
||||
@@ -14128,15 +14112,15 @@ synckit@^0.11.12:
|
||||
dependencies:
|
||||
"@pkgr/core" "^0.2.9"
|
||||
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz"
|
||||
integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
|
||||
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
|
||||
|
||||
terser-webpack-plugin@^5.3.17, terser-webpack-plugin@^5.3.9:
|
||||
version "5.3.17"
|
||||
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz#75ea98876297fbb190d2fbb395e982582b859a67"
|
||||
integrity sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==
|
||||
terser-webpack-plugin@^5.3.9, terser-webpack-plugin@^5.5.0:
|
||||
version "5.6.0"
|
||||
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz#8e7caad248183ab9e91ff08a83b0fc9f0439c3c3"
|
||||
integrity sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping" "^0.3.25"
|
||||
jest-worker "^27.4.5"
|
||||
@@ -14407,15 +14391,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.59.3:
|
||||
version "8.59.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.3.tgz#4a41d9007faa539a66292189e2795eeb0b9fca29"
|
||||
integrity sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==
|
||||
typescript-eslint@^8.59.4:
|
||||
version "8.59.4"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.4.tgz#834e3b53f4d1a764a985ceb8592c4a95d6a8da7c"
|
||||
integrity sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.59.3"
|
||||
"@typescript-eslint/parser" "8.59.3"
|
||||
"@typescript-eslint/typescript-estree" "8.59.3"
|
||||
"@typescript-eslint/utils" "8.59.3"
|
||||
"@typescript-eslint/eslint-plugin" "8.59.4"
|
||||
"@typescript-eslint/parser" "8.59.4"
|
||||
"@typescript-eslint/typescript-estree" "8.59.4"
|
||||
"@typescript-eslint/utils" "8.59.4"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
@@ -14970,22 +14954,21 @@ webpack-merge@^6.0.1:
|
||||
flat "^5.0.2"
|
||||
wildcard "^2.0.1"
|
||||
|
||||
webpack-sources@^3.3.4:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891"
|
||||
integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==
|
||||
webpack-sources@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.4.1.tgz#009d110999ebd9fb3a6fa8d32eec6f84d940e65d"
|
||||
integrity sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==
|
||||
|
||||
webpack-virtual-modules@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
webpack@^5.106.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.106.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.106.2.tgz#ca8174b4fd80f055cc5a45fcc5577d6db76c8ac5"
|
||||
integrity sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==
|
||||
webpack@^5.107.1, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.107.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.1.tgz#01ad63131b7c413f607cc00a8136f467c1f10af0"
|
||||
integrity sha512-mvdIWxj/H6QsfgDdH9djne3a5dYcmEmtsXGESkypaGN5jXjF/b+9KDlmTDQ2TKlFUeA2fI9Y65kihD30JOdB+Q==
|
||||
dependencies:
|
||||
"@types/eslint-scope" "^3.7.7"
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
"@webassemblyjs/ast" "^1.14.1"
|
||||
@@ -14995,20 +14978,20 @@ webpack@^5.106.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
acorn-import-phases "^1.0.3"
|
||||
browserslist "^4.28.1"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.20.0"
|
||||
es-module-lexer "^2.0.0"
|
||||
enhanced-resolve "^5.21.4"
|
||||
es-module-lexer "^2.1.0"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
glob-to-regexp "^0.4.1"
|
||||
graceful-fs "^4.2.11"
|
||||
loader-runner "^4.3.1"
|
||||
loader-runner "^4.3.2"
|
||||
mime-db "^1.54.0"
|
||||
neo-async "^2.6.2"
|
||||
schema-utils "^4.3.3"
|
||||
tapable "^2.3.0"
|
||||
terser-webpack-plugin "^5.3.17"
|
||||
terser-webpack-plugin "^5.5.0"
|
||||
watchpack "^2.5.1"
|
||||
webpack-sources "^3.3.4"
|
||||
webpack-sources "^3.4.1"
|
||||
|
||||
webpackbar@^7.0.0:
|
||||
version "7.0.0"
|
||||
|
||||
0
extensions/chat/PUT_FILES_HERE.txt
Normal file
0
extensions/chat/PUT_FILES_HERE.txt
Normal file
@@ -58,7 +58,7 @@ dependencies = [
|
||||
"flask-wtf>=1.1.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet>=3.0.3, <=3.5.0",
|
||||
"gunicorn>=22.0.0; sys_platform != 'win32'",
|
||||
"gunicorn>=25.3.0, <26; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
"holidays>=0.45, <1",
|
||||
@@ -137,7 +137,7 @@ databricks = [
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
|
||||
denodo = ["denodo-sqlalchemy>=1.0.6,<2.1.0"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
||||
drill = ["sqlalchemy-drill>=1.1.10, <2"]
|
||||
druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
|
||||
@@ -166,7 +166,7 @@ greenlet==3.1.1
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
gunicorn==23.0.0
|
||||
gunicorn==25.3.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
h11==0.16.0
|
||||
# via wsproto
|
||||
|
||||
@@ -388,7 +388,7 @@ grpcio==1.71.0
|
||||
# grpcio-status
|
||||
grpcio-status==1.60.1
|
||||
# via google-api-core
|
||||
gunicorn==23.0.0
|
||||
gunicorn==25.3.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -92,6 +92,26 @@ class Dimension:
|
||||
grain: Grain | None = None
|
||||
|
||||
|
||||
class AggregationType(str, enum.Enum):
|
||||
"""
|
||||
Aggregation function applied by a metric.
|
||||
|
||||
Additivity (across an arbitrary set of grouping dimensions):
|
||||
* ``SUM``, ``COUNT``: fully additive — sub-group sums roll up via ``sum``.
|
||||
* ``MIN``, ``MAX``: roll up via ``min`` / ``max`` of sub-group values.
|
||||
* ``AVG``, ``COUNT_DISTINCT``, ``OTHER``: not safely roll-uppable from
|
||||
sub-aggregates without auxiliary data.
|
||||
"""
|
||||
|
||||
SUM = "SUM"
|
||||
COUNT = "COUNT"
|
||||
MIN = "MIN"
|
||||
MAX = "MAX"
|
||||
AVG = "AVG"
|
||||
COUNT_DISTINCT = "COUNT_DISTINCT"
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Metric:
|
||||
id: str
|
||||
@@ -100,6 +120,7 @@ class Metric:
|
||||
|
||||
definition: str
|
||||
description: str | None = None
|
||||
aggregation: AggregationType | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -17,12 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { PropsWithChildren, ReactNode, SyntheticEvent } from 'react';
|
||||
import {
|
||||
ResizableBox,
|
||||
ResizableBoxProps,
|
||||
ResizeCallbackData,
|
||||
} from 'react-resizable';
|
||||
import { ReactNode, SyntheticEvent } from 'react';
|
||||
import { ResizableBox, ResizeCallbackData } from 'react-resizable';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
|
||||
import 'react-resizable/css/styles.css';
|
||||
@@ -46,14 +42,16 @@ export type Size = ResizeCallbackData['size'];
|
||||
|
||||
export default function ResizablePanel({
|
||||
children,
|
||||
heading = undefined,
|
||||
heading,
|
||||
initialSize = { width: 500, height: 300 },
|
||||
minConstraints = [100, 100] as [number, number],
|
||||
onResize,
|
||||
...props
|
||||
}: PropsWithChildren<Omit<ResizableBoxProps, 'width' | 'height'>> & {
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
heading?: ReactNode;
|
||||
initialSize?: Size;
|
||||
minConstraints?: [number, number];
|
||||
onResize?: (e: SyntheticEvent, data: ResizeCallbackData) => void;
|
||||
}) {
|
||||
const { width, height } = initialSize;
|
||||
return (
|
||||
@@ -61,16 +59,14 @@ export default function ResizablePanel({
|
||||
className="panel"
|
||||
width={width}
|
||||
height={height}
|
||||
axis="both"
|
||||
minConstraints={minConstraints}
|
||||
onResize={
|
||||
onResize
|
||||
? (e: SyntheticEvent, data: ResizeCallbackData) => {
|
||||
const { size } = data;
|
||||
onResize(e, { ...data, size });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
maxConstraints={[Infinity, Infinity]}
|
||||
handleSize={[20, 20]}
|
||||
lockAspectRatio={false}
|
||||
resizeHandles={['se']}
|
||||
transformScale={1}
|
||||
onResize={onResize}
|
||||
>
|
||||
<>
|
||||
{heading ? <div className="panel-heading">{heading}</div> : null}
|
||||
|
||||
561
superset-frontend/package-lock.json
generated
561
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -112,7 +112,7 @@
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@googleapis/sheets": "^13.0.1",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -166,7 +166,7 @@
|
||||
"antd": "^5.26.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^1.1.0",
|
||||
"content-disposition": "^2.0.0",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
@@ -190,8 +190,8 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.23.1",
|
||||
"markdown-to-jsx": "^9.8.0",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"markdown-to-jsx": "^9.8.1",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^5.2.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -276,7 +276,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/plugin-emotion": "^14.9.0",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -289,12 +289,12 @@
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-resizable": "^4.0.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
@@ -303,14 +303,14 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.29",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -350,13 +350,13 @@
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.65.0",
|
||||
"oxlint": "^1.66.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
"react-refresh": "^0.18.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-resizable": "^4.0.1",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -365,14 +365,14 @@
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.0",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"yeoman-generator": "^8.1.2",
|
||||
"yeoman-generator": "^8.2.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"types": "./lib/authentication/index.d.ts",
|
||||
"default": "./lib/authentication/index.js"
|
||||
},
|
||||
"./dashboard": {
|
||||
"types": "./lib/dashboard/index.d.ts",
|
||||
"default": "./lib/dashboard/index.js"
|
||||
},
|
||||
"./dataset": {
|
||||
"types": "./lib/dataset/index.d.ts",
|
||||
"default": "./lib/dataset/index.js"
|
||||
},
|
||||
"./explore": {
|
||||
"types": "./lib/explore/index.d.ts",
|
||||
"default": "./lib/explore/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"
|
||||
|
||||
@@ -17,23 +17,9 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Manifest schema for Superset extension contributions.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { Command } from '../commands';
|
||||
import { View } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
import { Editor } from '../editors';
|
||||
|
||||
/**
|
||||
* Valid locations within SQL Lab.
|
||||
*/
|
||||
export type SqlLabLocation =
|
||||
| 'leftSidebar'
|
||||
| 'rightSidebar'
|
||||
@@ -43,43 +29,14 @@ export type SqlLabLocation =
|
||||
| 'results'
|
||||
| 'queryHistory';
|
||||
|
||||
/**
|
||||
* Nested structure for view contributions by scope and location.
|
||||
* @example
|
||||
* {
|
||||
* sqllab: {
|
||||
* panels: [{ id: "my-ext.panel", name: "My Panel" }],
|
||||
* leftSidebar: [{ id: "my-ext.sidebar", name: "My Sidebar" }]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
/** Valid locations within the app shell (persist across all routes). */
|
||||
export type AppLocation = 'chatbot';
|
||||
|
||||
export interface ViewContributions {
|
||||
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
|
||||
app?: Partial<Record<AppLocation, View[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested structure for menu contributions by scope and location.
|
||||
* @example
|
||||
* {
|
||||
* sqllab: {
|
||||
* editor: { primary: [...], secondary: [...] }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface MenuContributions {
|
||||
sqllab?: Partial<Record<SqlLabLocation, Menu>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
||||
*/
|
||||
export interface Contributions {
|
||||
/** List of commands. */
|
||||
commands: Command[];
|
||||
/** Nested mapping of menu contributions by scope and location. */
|
||||
menus: MenuContributions;
|
||||
/** Nested mapping of view contributions by scope and location. */
|
||||
views: ViewContributions;
|
||||
/** List of editors. */
|
||||
editors?: Editor[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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 Dashboard namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes dashboard identity and filter state as a stable semantic API.
|
||||
* Extensions must not depend on the Redux dashboard slice structure directly.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* A single native filter's current selected value(s).
|
||||
* The value type is intentionally kept as `unknown` because filter values
|
||||
* are heterogeneous (date ranges, string lists, numbers, etc.).
|
||||
*/
|
||||
export interface FilterValue {
|
||||
/** The filter's stable id. */
|
||||
filterId: string;
|
||||
/** Display label of the filter. */
|
||||
label: string;
|
||||
/** Currently applied value, or `null` when the filter is cleared. */
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized dashboard context exposed to extensions on the Dashboard page.
|
||||
*/
|
||||
export interface DashboardContext {
|
||||
/** Numeric dashboard id. */
|
||||
dashboardId: number;
|
||||
/** Display title of the dashboard. */
|
||||
title: string;
|
||||
/**
|
||||
* Active native filter values keyed by filter id.
|
||||
* Only includes filters that have a value applied.
|
||||
*/
|
||||
filters: FilterValue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized dashboard context for the page currently being viewed,
|
||||
* or `undefined` when the user is not on a Dashboard page.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const dash = dashboard.getCurrentDashboard();
|
||||
* if (dash) {
|
||||
* console.log(dash.title, dash.filters);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getCurrentDashboard(): DashboardContext | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when the dashboard identity or its active filter values change.
|
||||
* Fired on native filter value changes and on navigation to a different dashboard.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = dashboard.onDidChangeDashboard(dash => {
|
||||
* chatbot.updateContext({ dashboard: dash });
|
||||
* });
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangeDashboard: Event<DashboardContext>;
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 Dataset namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes the dataset currently being viewed as a stable semantic API.
|
||||
* Aligned with backend-enforced dataset visibility and column-access semantics.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* Normalized dataset context exposed to extensions on the Dataset page.
|
||||
*/
|
||||
export interface DatasetContext {
|
||||
/** Numeric dataset id. */
|
||||
datasetId: number;
|
||||
/** Display name (table name or virtual dataset name). */
|
||||
datasetName: string;
|
||||
/** Schema the dataset belongs to, if applicable. */
|
||||
schema: string | null;
|
||||
/** Catalog the dataset belongs to, if applicable. */
|
||||
catalog: string | null;
|
||||
/** Database name backing this dataset. */
|
||||
databaseName: string | null;
|
||||
/** Whether this is a virtual (SQL-defined) dataset. */
|
||||
isVirtual: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized dataset context for the page currently being viewed,
|
||||
* or `undefined` when the user is not on a Dataset page.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const ds = dataset.getCurrentDataset();
|
||||
* if (ds) {
|
||||
* console.log(ds.datasetName, ds.schema);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getCurrentDataset(): DatasetContext | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when the focused dataset changes (e.g. the user navigates to a
|
||||
* different dataset detail page).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = dataset.onDidChangeDataset(ds => {
|
||||
* chatbot.updateContext({ dataset: ds });
|
||||
* });
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangeDataset: Event<DatasetContext>;
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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 Explore namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes the current chart/explore context as a stable semantic API.
|
||||
* Normalized over Explore Redux state — extensions must not depend on
|
||||
* the Redux slice structure directly.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* Normalized chart context exposed to extensions during an Explore session.
|
||||
* Covers saved chart identity and transient editing context; excludes raw
|
||||
* form-data internals and datasource-implementation details.
|
||||
*/
|
||||
export interface ChartContext {
|
||||
/** The saved chart id, or `null` when the chart has not been persisted. */
|
||||
chartId: number | null;
|
||||
/** Display name of the saved chart, or `null` for a new/unsaved chart. */
|
||||
chartName: string | null;
|
||||
/** The visualization type currently selected in the editor. */
|
||||
vizType: string;
|
||||
/** Id of the datasource backing the chart (physical or virtual dataset). */
|
||||
datasourceId: number | null;
|
||||
/** Human-readable datasource name. */
|
||||
datasourceName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized chart context for the active Explore session, or
|
||||
* `undefined` when the user is not on the Explore page.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const chart = explore.getCurrentChart();
|
||||
* if (chart) {
|
||||
* console.log(chart.vizType, chart.chartName);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getCurrentChart(): ChartContext | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when the chart context changes within the active Explore session
|
||||
* (e.g. when the viz type, datasource, or saved name changes).
|
||||
* Not fired during route changes — subscribe to `navigation.onDidChangePage` for those.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = explore.onDidChangeChart(chart => {
|
||||
* chatbot.updateContext({ chart });
|
||||
* });
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangeChart: Event<ChartContext>;
|
||||
@@ -19,9 +19,13 @@
|
||||
export * as common from './common';
|
||||
export * as authentication from './authentication';
|
||||
export * as commands from './commands';
|
||||
export * as dashboard from './dashboard';
|
||||
export * as dataset from './dataset';
|
||||
export * as editors from './editors';
|
||||
export * as explore from './explore';
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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 (P3).
|
||||
*
|
||||
* 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 — use the surface-specific namespace
|
||||
* (`explore`, `dashboard`, `dataset`) to retrieve entity payloads.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* The set of top-level application surfaces.
|
||||
* `'other'` covers any route not explicitly enumerated.
|
||||
*/
|
||||
export type PageType =
|
||||
| 'dashboard'
|
||||
| 'explore'
|
||||
| 'sqllab'
|
||||
| 'dataset'
|
||||
| 'home'
|
||||
| 'other';
|
||||
|
||||
/**
|
||||
* Returns the current page surface type.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const pageType = navigation.getPageType();
|
||||
* if (pageType === 'dashboard') {
|
||||
* const ctx = dashboard.getCurrentDashboard();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getPageType(): PageType;
|
||||
|
||||
/**
|
||||
* Event fired whenever the user navigates to a different surface.
|
||||
* Use the surface-specific namespace to read entity context after the event.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = navigation.onDidChangePage(pageType => {
|
||||
* if (pageType === 'dashboard') {
|
||||
* const ctx = dashboard.getCurrentDashboard();
|
||||
* }
|
||||
* });
|
||||
* // later:
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangePage: Event<PageType>;
|
||||
@@ -20,19 +20,12 @@
|
||||
/**
|
||||
* @fileoverview Views registration API for Superset extensions.
|
||||
*
|
||||
* This module provides functions for registering custom React views
|
||||
* at specific locations in the Superset UI. Views are registered as
|
||||
* module-level side effects at import time.
|
||||
* Extensions register React views at named locations using `registerView`.
|
||||
* Registrations happen as module-level side effects at import time.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { views } from '@apache-superset/core';
|
||||
*
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
|
||||
* () => <ResultStatsPanel />,
|
||||
* );
|
||||
* ```
|
||||
* Built-in locations:
|
||||
* - `sqllab.panels` / `sqllab.rightSidebar` / … — SQL Lab surface
|
||||
* - `superset.chatbot` — app-shell chatbot bubble (singleton; host renders one)
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
@@ -48,20 +41,23 @@ export interface View {
|
||||
name: string;
|
||||
/** Optional description of the view, for display in contribution manifests. */
|
||||
description?: string;
|
||||
/**
|
||||
* Optional icon identifier for the view, used in admin pickers and manifest
|
||||
* listings. Static — set once at registerView() time.
|
||||
* Dynamic icon states (e.g. notification badge) are the extension's concern.
|
||||
*/
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a custom view at a specific UI location.
|
||||
*
|
||||
* The view provider function is called when the UI renders the location,
|
||||
* and should return a React element to display.
|
||||
*
|
||||
* @param view The view descriptor (id and name).
|
||||
* @param location The location where this view should appear (e.g. "sqllab.panels").
|
||||
* @param view The view descriptor (id, name, and optional icon/description).
|
||||
* @param location The location where this view should appear.
|
||||
* @param provider A function that returns the React element to render.
|
||||
* @returns A Disposable that unregisters the view when disposed.
|
||||
*
|
||||
* @example
|
||||
* @example SQL Lab panel
|
||||
* ```typescript
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats' },
|
||||
@@ -69,6 +65,15 @@ export interface View {
|
||||
* () => <ResultStatsPanel />,
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example Chatbot bubble (`superset.chatbot` — singleton, host renders one)
|
||||
* ```typescript
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
|
||||
* 'superset.chatbot',
|
||||
* () => <ChatbotApp />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerView(
|
||||
view: View,
|
||||
@@ -76,6 +81,21 @@ export declare function registerView(
|
||||
provider: () => ReactElement,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
* Narrowed descriptor for chatbot contributions (`superset.chatbot` location).
|
||||
*
|
||||
* Extension authors should use this type when calling `registerView` for the
|
||||
* chatbot area. It is identical to {@link View} but makes the registration
|
||||
* intent explicit and allows future narrowing (e.g. required `icon`).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const chatbot: ChatbotView = { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' };
|
||||
* views.registerView(chatbot, 'superset.chatbot', () => <ChatbotApp />);
|
||||
* ```
|
||||
*/
|
||||
export type ChatbotView = View;
|
||||
|
||||
/**
|
||||
* Retrieves all views registered at a specific location.
|
||||
*
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -76,7 +76,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
@@ -371,3 +371,37 @@ test('should handle large datasets with pagination', () => {
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should reset to first page when data reduces below current page', async () => {
|
||||
// Start with 30 items, 10 per page = 3 pages
|
||||
const initialData = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: i,
|
||||
age: 20 + i,
|
||||
name: `Person ${i}`,
|
||||
}));
|
||||
|
||||
const props = {
|
||||
...mockedProps,
|
||||
data: initialData,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
const { rerender } = render(<TableView {...props} />);
|
||||
|
||||
// Navigate to page 3 (last page)
|
||||
const page3 = screen.getByRole('listitem', { name: '3' });
|
||||
await userEvent.click(page3);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('21-30 of 30')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reduce data to only 5 items (fewer than current page would show)
|
||||
const reducedData = initialData.slice(0, 5);
|
||||
rerender(<TableView {...props} data={reducedData} />);
|
||||
|
||||
// Should reset to page 1 since page 3 no longer exists
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1-5 of 5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,6 +246,21 @@ const RawTableView = ({
|
||||
}
|
||||
}, [initialSortBy, onServerPagination, serverPagination, sortBy]);
|
||||
|
||||
// Reset to first page when current page exceeds available pages
|
||||
// (e.g., when filtering reduces the data below the current page)
|
||||
const pageCount = Math.ceil(data.length / effectivePageSize);
|
||||
useEffect(() => {
|
||||
if (
|
||||
withPagination &&
|
||||
!serverPagination &&
|
||||
!loading &&
|
||||
pageIndex > pageCount - 1 &&
|
||||
pageCount > 0
|
||||
) {
|
||||
setPageIndex(0);
|
||||
}
|
||||
}, [withPagination, serverPagination, loading, pageIndex, pageCount]);
|
||||
|
||||
return (
|
||||
<TableViewStyles {...props} ref={tableRef}>
|
||||
<TableCollection
|
||||
|
||||
@@ -63,7 +63,8 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
|
||||
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
|
||||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
|
||||
) {
|
||||
return BigInt(value);
|
||||
// toFixed() avoids scientific notation, which BigInt() rejects.
|
||||
return BigInt(value.toFixed());
|
||||
}
|
||||
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
|
||||
// // TODO: clean up after json-bigint>1.0.1 is released
|
||||
|
||||
@@ -183,6 +183,26 @@ describe('parseResponse()', () => {
|
||||
expect(responseBigNumber.json.constructor).toEqual('constructor');
|
||||
});
|
||||
|
||||
test('handles big numbers in scientific notation when `parseMethod=json-bigint`', async () => {
|
||||
const mockScientificUrl = '/mock/get/scientific';
|
||||
const mockScientificPayload =
|
||||
'{ "big_double": 4.799703045723905e+32, "negative_big": -4.799703045723905e+32, "small": 1 }';
|
||||
fetchMock.get(mockScientificUrl, mockScientificPayload);
|
||||
|
||||
const responseBigNumber = await parseResponse(
|
||||
callApi({ url: mockScientificUrl, method: 'GET' }),
|
||||
'json-bigint',
|
||||
);
|
||||
|
||||
expect(`${responseBigNumber.json.big_double}`).toEqual(
|
||||
'479970304572390500000000000000000',
|
||||
);
|
||||
expect(`${responseBigNumber.json.negative_big}`).toEqual(
|
||||
'-479970304572390500000000000000000',
|
||||
);
|
||||
expect(responseBigNumber.json.small).toEqual(1);
|
||||
});
|
||||
|
||||
test('rejects if request.ok=false', async () => {
|
||||
expect.assertions(3);
|
||||
const mockNotOkayUrl = '/mock/notokay/url';
|
||||
|
||||
@@ -95,8 +95,11 @@ class FakeMessageChannel {
|
||||
const port2 = new FakeMessagePort();
|
||||
port1.otherPort = port2;
|
||||
port2.otherPort = port1;
|
||||
this.port1 = port1;
|
||||
this.port2 = port2;
|
||||
// FakeMessagePort only implements the subset of MessagePort that
|
||||
// Switchboard exercises; cast at the boundary so the fake satisfies
|
||||
// the consumer signature without weakening the production type.
|
||||
this.port1 = port1 as unknown as MessagePort;
|
||||
this.port2 = port2 as unknown as MessagePort;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ function isError(message: Message): message is ErrorMessage {
|
||||
* Calling methods on the switchboard causes messages to be sent through the channel.
|
||||
*/
|
||||
export class Switchboard {
|
||||
port: MessagePort;
|
||||
port!: MessagePort;
|
||||
|
||||
name = '';
|
||||
|
||||
@@ -97,9 +97,9 @@ export class Switchboard {
|
||||
// used to make unique ids
|
||||
incrementor = 1;
|
||||
|
||||
debugMode: boolean;
|
||||
debugMode = false;
|
||||
|
||||
private isInitialised: boolean;
|
||||
private isInitialised = false;
|
||||
|
||||
constructor(params?: Params) {
|
||||
if (!params) {
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
VisualMapComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
type DataZoomComponentOption,
|
||||
ToolboxComponent,
|
||||
GraphicComponent,
|
||||
AriaComponent,
|
||||
@@ -280,12 +281,56 @@ function Echart(
|
||||
|
||||
const notMerge = !isDashboardRefreshing;
|
||||
chartRef.current?.dispatchAction({ type: 'hideTip' });
|
||||
// setOption(notMerge:true) replaces the dataZoom config, dropping any
|
||||
// range the user has engaged. Preserve it across the call.
|
||||
const previousZoom = notMerge
|
||||
? (chartRef.current?.getOption() as { dataZoom?: DataZoomComponentOption[] })
|
||||
?.dataZoom
|
||||
: undefined;
|
||||
chartRef.current?.setOption(themedEchartOptions, {
|
||||
notMerge,
|
||||
replaceMerge: notMerge ? undefined : ['series'],
|
||||
// lazyUpdate defers render, causing tooltip crashes on stale shapes (#39247)
|
||||
lazyUpdate: false,
|
||||
});
|
||||
if (previousZoom?.length) {
|
||||
// Skip restore when the new option reshapes dataZoom (different count
|
||||
// means index-based restore could land on the wrong component).
|
||||
const newZoom = (
|
||||
chartRef.current?.getOption() as {
|
||||
dataZoom?: DataZoomComponentOption[];
|
||||
}
|
||||
)?.dataZoom;
|
||||
if (newZoom?.length === previousZoom.length) {
|
||||
const batch = previousZoom
|
||||
.map((dz, dataZoomIndex) => ({
|
||||
dataZoomIndex,
|
||||
start: dz.start,
|
||||
end: dz.end,
|
||||
startValue: dz.startValue,
|
||||
endValue: dz.endValue,
|
||||
}))
|
||||
.filter(b => {
|
||||
const hasAny =
|
||||
b.start !== undefined ||
|
||||
b.end !== undefined ||
|
||||
b.startValue !== undefined ||
|
||||
b.endValue !== undefined;
|
||||
if (!hasAny) return false;
|
||||
// Default full-range zoom is functionally identical to the
|
||||
// fresh state setOption already produces — skip the dispatch.
|
||||
const isDefaultRange =
|
||||
b.start === 0 &&
|
||||
b.end === 100 &&
|
||||
b.startValue === undefined &&
|
||||
b.endValue === undefined;
|
||||
return !isDefaultRange;
|
||||
});
|
||||
if (batch.length) {
|
||||
chartRef.current?.dispatchAction({ type: 'dataZoom', batch });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
|
||||
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 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 { EChartsCoreOption } from 'echarts/core';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
|
||||
const setOption = jest.fn();
|
||||
const on = jest.fn();
|
||||
const off = jest.fn();
|
||||
const resize = jest.fn();
|
||||
const dispose = jest.fn();
|
||||
const dispatchAction = jest.fn();
|
||||
const getOption = jest.fn();
|
||||
|
||||
const mockInstance = {
|
||||
setOption,
|
||||
on,
|
||||
off,
|
||||
resize,
|
||||
dispose,
|
||||
dispatchAction,
|
||||
getOption,
|
||||
getZr: () => ({ on: jest.fn(), off: jest.fn() }),
|
||||
};
|
||||
|
||||
jest.mock('echarts/core', () => ({
|
||||
__esModule: true,
|
||||
use: jest.fn(),
|
||||
init: jest.fn(() => mockInstance),
|
||||
registerLocale: jest.fn(),
|
||||
}));
|
||||
jest.mock('echarts/charts', () => ({}));
|
||||
jest.mock('echarts/renderers', () => ({}));
|
||||
jest.mock('echarts/components', () => ({}));
|
||||
jest.mock('echarts/features', () => ({}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import Echart from '../../src/components/Echart';
|
||||
|
||||
const renderEchart = (echartOptions: EChartsCoreOption) => {
|
||||
const refs = { divRef: undefined };
|
||||
return render(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={echartOptions}
|
||||
refs={refs}
|
||||
/>,
|
||||
{ useRedux: true, useTheme: true },
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setOption.mockClear();
|
||||
on.mockClear();
|
||||
off.mockClear();
|
||||
resize.mockClear();
|
||||
dispatchAction.mockClear();
|
||||
getOption.mockReset();
|
||||
});
|
||||
|
||||
test('preserves user dataZoom range across setOption(notMerge)', async () => {
|
||||
// After the user has zoomed, ECharts reports the current dataZoom range
|
||||
// via getOption().dataZoom. We expect Echart to capture this before
|
||||
// setOption replaces the option payload, then restore it via dispatchAction.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ start: 12, end: 48 }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
|
||||
// Trigger another setOption call by changing the echartOptions reference
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dispatchAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'dataZoom',
|
||||
batch: [
|
||||
expect.objectContaining({ dataZoomIndex: 0, start: 12, end: 48 }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not restore when no prior zoom range exists', async () => {
|
||||
// Fresh chart with no engaged zoom: dataZoom config has no start/end.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ type: 'slider', show: true }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not restore when prior zoom is at default full range', async () => {
|
||||
// ECharts populates start:0/end:100 on slider dataZoom by default, so
|
||||
// every untouched timeseries would otherwise dispatch a redundant action
|
||||
// on each re-render. Skip the dispatch when the range is just the default.
|
||||
getOption.mockReturnValue({
|
||||
dataZoom: [{ type: 'slider', show: true, start: 0, end: 100 }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not restore when the new option reshapes dataZoom', async () => {
|
||||
// 1st render starts with no engaged zoom; 2nd render captures an engaged
|
||||
// range but the post-setOption dataZoom has a different count, so
|
||||
// index-based restore could write to the wrong component. Skip in that case.
|
||||
getOption
|
||||
// 1st render: previousZoom + newZoom (no engaged values, nothing to dispatch)
|
||||
.mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
|
||||
.mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
|
||||
// 2nd render: previousZoom has user range, but newZoom has 2 entries
|
||||
.mockReturnValueOnce({ dataZoom: [{ start: 12, end: 48 }] })
|
||||
.mockReturnValueOnce({
|
||||
dataZoom: [{ start: 12, end: 48 }, { type: 'inside' }],
|
||||
});
|
||||
|
||||
const { rerender } = renderEchart({ xAxis: {}, series: [] });
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<Echart
|
||||
width={400}
|
||||
height={300}
|
||||
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
|
||||
refs={{ divRef: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
|
||||
const dataZoomCalls = dispatchAction.mock.calls.filter(
|
||||
([action]) => action?.type === 'dataZoom',
|
||||
);
|
||||
expect(dataZoomCalls).toHaveLength(0);
|
||||
});
|
||||
@@ -27,7 +27,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.23.1",
|
||||
"mapbox-gl": "^3.24.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -27,7 +27,7 @@ jest.mock('../../DeckGLContainer', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('../../factory', () => ({
|
||||
createDeckGLComponent: jest.fn(() => () => null),
|
||||
createCategoricalDeckGLComponent: jest.fn(() => () => null),
|
||||
GetLayerType: {},
|
||||
}));
|
||||
|
||||
@@ -53,6 +53,14 @@ const mockPayload = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockLayerParams = {
|
||||
onContextMenu: jest.fn(),
|
||||
filterState: undefined,
|
||||
setDataMask: jest.fn(),
|
||||
setTooltip: jest.fn(),
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
test('getLayer uses line_width_unit from formData', () => {
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
@@ -117,3 +125,518 @@ test('getPoints extracts points from path data', () => {
|
||||
expect(points[0]).toEqual([0, 0]);
|
||||
expect(points[2]).toEqual([2, 2]);
|
||||
});
|
||||
|
||||
test('Fixed width mode returns constant width for all paths', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const widths = data.map(d => d.width);
|
||||
|
||||
widths.forEach(width => {
|
||||
expect(width).toBe(widths[0]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Fixed width mode applies multiplier correctly', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width_multiplier: 3,
|
||||
min_width: 1,
|
||||
max_width: 100,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBe(15);
|
||||
});
|
||||
|
||||
test('Fixed width mode enforces minimum width bound', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 0.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 2,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('Fixed width mode enforces maximum width bound', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test('Fixed width mode defaults width to 1 when no width is provided', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: undefined,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
expect(data[0].width).toBe(1);
|
||||
});
|
||||
|
||||
test('Metric mode normalizes widths proportionally between min and max bounds', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const widths = data.map((d: any) => d.width);
|
||||
|
||||
expect(widths[0]).toBeCloseTo(1);
|
||||
expect(widths[1]).toBeCloseTo(10.5);
|
||||
expect(widths[2]).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
test('Metric mode applies multiplier after normalization', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 2,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].width).toBeCloseTo(2);
|
||||
expect(data[1].width).toBe(20);
|
||||
});
|
||||
|
||||
test('Metric mode enforces bounds after multiplier', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 5,
|
||||
max_width: 15,
|
||||
line_width_multiplier: 10,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
data.forEach((d: any) => {
|
||||
expect(d.width).toBeGreaterThanOrEqual(5);
|
||||
expect(d.width).toBeLessThanOrEqual(15);
|
||||
});
|
||||
});
|
||||
|
||||
test('Metric mode handles equal width values.', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].width).toBe(data[1].width);
|
||||
});
|
||||
|
||||
test('Metric mode handles null width values', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
width: null,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
width: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
line_width: { type: 'metric', value: 'some_metric' },
|
||||
min_width: 1,
|
||||
max_width: 20,
|
||||
line_width_multiplier: 1,
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[1].width).toBe(1);
|
||||
expect(data[0].width).toBeCloseTo(1);
|
||||
expect(data[2].width).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
test('Fixed color mode returns same color for all paths', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: {
|
||||
...mockFormData,
|
||||
color_picker: { r: 255, g: 100, b: 50, a: 1 },
|
||||
},
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
const expectedColor = [255, 100, 50, 255];
|
||||
|
||||
data.forEach((d: any) => {
|
||||
expect(d.color).toEqual(expectedColor);
|
||||
});
|
||||
});
|
||||
|
||||
test('Categorical mode preserves distinct colors for selected categories', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
cat_color: 'A',
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
color: [0, 0, 255, 255],
|
||||
cat_color: 'B',
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
cat_color: 'A',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].color).toEqual(data[2].color);
|
||||
expect(data[0].color).not.toEqual(data[1].color);
|
||||
});
|
||||
|
||||
test('Breakpoint mode preserves colors assigned by addColor based on metric ranges', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
metric: 50,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
color: [0, 0, 255, 255],
|
||||
metric: 200,
|
||||
},
|
||||
{
|
||||
path: [
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
color: [255, 0, 0, 255],
|
||||
metric: 75,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
formData: mockFormData,
|
||||
payload,
|
||||
...mockLayerParams,
|
||||
});
|
||||
|
||||
const data = layer.props.data as any[];
|
||||
|
||||
expect(data[0].color).toEqual(data[2].color);
|
||||
expect(data[0].color).not.toEqual(data[1].color);
|
||||
});
|
||||
|
||||
@@ -21,13 +21,14 @@ import { PathLayer } from '@deck.gl/layers';
|
||||
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||
import { commonLayerProps } from '../common';
|
||||
import sandboxedEval from '../../utils/sandbox';
|
||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
|
||||
import { Point } from '../../types';
|
||||
import {
|
||||
createTooltipContent,
|
||||
CommonTooltipRows,
|
||||
} from '../../utilities/tooltipUtils';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
import { isMetricValue } from '../utils/metricUtils';
|
||||
|
||||
function setTooltipContent(formData: QueryFormData) {
|
||||
const defaultTooltipGenerator = (o: JsonObject) => (
|
||||
@@ -50,14 +51,69 @@ export const getLayer: GetLayerType<PathLayer> = function ({
|
||||
emitCrossFilters,
|
||||
}) {
|
||||
const fd = formData;
|
||||
const c = fd.color_picker;
|
||||
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
|
||||
let data = payload.data.features.map((feature: JsonObject) => ({
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width: fd.line_width,
|
||||
color: fixedColor,
|
||||
}));
|
||||
let data = payload.data.features.map((feature: JsonObject) => {
|
||||
if (feature.color) {
|
||||
return { ...feature };
|
||||
}
|
||||
|
||||
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
|
||||
const color = [c.r, c.g, c.b, 255 * c.a];
|
||||
|
||||
return {
|
||||
...feature,
|
||||
path: feature.path,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
// Variables for width scaling and normalization
|
||||
const minWidth = Number(fd.min_width) || 1; // defaulted to 1
|
||||
const maxWidth = Number(fd.max_width) || 20; // defaulted to 20
|
||||
const multiplier = Number(fd.line_width_multiplier) || 1; // defaulted to 1
|
||||
|
||||
const widths = data.map((d: JsonObject) => d.width).filter(Number.isFinite);
|
||||
|
||||
// Metric or fixed value
|
||||
const isMetricWidth = isMetricValue(fd.line_width);
|
||||
|
||||
if (isMetricWidth) {
|
||||
// Get minimum and maximum widths in data set
|
||||
const minVal = widths.length > 0 ? Math.min(...widths) : minWidth;
|
||||
const maxVal = widths.length > 0 ? Math.max(...widths) : maxWidth;
|
||||
|
||||
data = data.map((d: JsonObject) => {
|
||||
if (d.width == null) return { ...d, width: minWidth };
|
||||
|
||||
const normalized =
|
||||
maxVal === minVal ? 0.5 : (d.width - minVal) / (maxVal - minVal);
|
||||
|
||||
// Map within range of min + max
|
||||
let width = minWidth + normalized * (maxWidth - minWidth);
|
||||
|
||||
// Apply scaling multiplier
|
||||
width *= multiplier;
|
||||
|
||||
// Enforce minimum and maximum width bounds
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
|
||||
return { ...d, width };
|
||||
});
|
||||
} else {
|
||||
// Fixed width mode
|
||||
// Allows for use with legacy charts
|
||||
const fixedWidth =
|
||||
typeof fd.line_width === 'number'
|
||||
? fd.line_width
|
||||
: typeof fd.line_width === 'object' && fd.line_width?.type === 'fix'
|
||||
? Number(fd.line_width.value)
|
||||
: undefined;
|
||||
|
||||
data = data.map((d: JsonObject) => {
|
||||
let width = (d.width ?? fixedWidth ?? 1) * multiplier;
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
return { ...d, width };
|
||||
});
|
||||
}
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
@@ -66,13 +122,15 @@ export const getLayer: GetLayerType<PathLayer> = function ({
|
||||
|
||||
return new PathLayer({
|
||||
id: `path-layer-${fd.slice_id}` as const,
|
||||
getColor: (d: any) => d.color,
|
||||
getColor: (d: any) => d.color || [0, 0, 0, 255],
|
||||
getPath: (d: any) => d.path,
|
||||
getWidth: (d: any) => d.width,
|
||||
data,
|
||||
rounded: true,
|
||||
widthScale: 1,
|
||||
widthUnits: fd.line_width_unit,
|
||||
widthMinPixels: Number(fd.min_width) || undefined,
|
||||
widthMaxPixels: Number(fd.max_width) || undefined,
|
||||
...commonLayerProps({
|
||||
formData: fd,
|
||||
setTooltip,
|
||||
@@ -101,13 +159,23 @@ export const getHighlightLayer: GetLayerType<PathLayer> = function ({
|
||||
filterState,
|
||||
}) {
|
||||
const fd = formData;
|
||||
const minWidth = Number(fd.min_width) || 1;
|
||||
const maxWidth = Number(fd.max_width) || 20;
|
||||
const multiplier = Number(fd.line_width_multiplier) || 1;
|
||||
const fixedColor = HIGHLIGHT_COLOR_ARRAY;
|
||||
let data = payload.data.features.map((feature: JsonObject) => ({
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width: fd.line_width,
|
||||
color: fixedColor,
|
||||
}));
|
||||
let data = payload.data.features.map((feature: JsonObject) => {
|
||||
const baseWidth = Number.isFinite(feature.width) ? feature.width : 1;
|
||||
let width = baseWidth * multiplier;
|
||||
|
||||
width = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
|
||||
return {
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width,
|
||||
color: fixedColor,
|
||||
};
|
||||
});
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
@@ -128,7 +196,13 @@ export const getHighlightLayer: GetLayerType<PathLayer> = function ({
|
||||
rounded: true,
|
||||
widthScale: 1,
|
||||
widthUnits: fd.line_width_unit,
|
||||
widthMinPixels: Number(fd.min_width) || undefined,
|
||||
widthMaxPixels: Number(fd.max_width) || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
|
||||
export default createCategoricalDeckGLComponent(
|
||||
getLayer,
|
||||
getPoints,
|
||||
getHighlightLayer,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 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 buildQuery, { DeckPathFormData } from './buildQuery';
|
||||
|
||||
const baseFormData: DeckPathFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'deck_path',
|
||||
line_column: 'path_json',
|
||||
line_type: 'json',
|
||||
row_limit: 100,
|
||||
};
|
||||
|
||||
test('Path buildQuery should not include metric when line_width is fixed type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle numeric line_width value with fixed type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle missing line_width', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should include metric when line_width is metric type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'COUNT(*)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('COUNT(*)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should add line_column to groupby when using width metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.groupby).toContain('path_json');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc SQL metric for line_width', () => {
|
||||
const adhocMetric = {
|
||||
label: 'custom_width',
|
||||
expressionType: 'SQL' as const,
|
||||
sqlExpression: 'SUM(weight) / COUNT(*)',
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: adhocMetric,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc SIMPLE metric for line_width', () => {
|
||||
const adhocMetric = {
|
||||
label: 'AVG(traffic)',
|
||||
expressionType: 'SIMPLE' as const,
|
||||
column: { column_name: 'traffic' },
|
||||
aggregate: 'AVG' as const,
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: adhocMetric,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle metric type with undefined value', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should not duplicate width metric if already in metrics', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['AVG(weight)'],
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Path buildQuery should preserve existing metrics when adding width metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['COUNT(*)'],
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('COUNT(*)');
|
||||
expect(query.metrics).toContain('AVG(weight)');
|
||||
expect(query.metrics).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('Path buildQuery should not modify existing metrics for fixed width', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['COUNT(*)', 'SUM(value)'],
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual(['COUNT(*)', 'SUM(value)']);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle undefined value in metric type gracefully', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
// Should not add anything when value is undefined
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle line_width with undefined type', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: undefined,
|
||||
value: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
// ─── Dimension (categorical color) ───
|
||||
|
||||
test('Path buildQuery should include dimension column when specified', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
dimension: 'route_type',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.columns).toContain('route_type');
|
||||
});
|
||||
|
||||
test('Path buildQuery should include breakpoint_metric when specified', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should add line_column to groupby when using breakpoint metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.groupby).toContain('path_json');
|
||||
});
|
||||
|
||||
test('Path buildQuery should not duplicate breakpoint metric if already in metrics', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
metrics: ['AVG(speed)'],
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toHaveLength(1);
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle breakpoint_metric and line_width metric together', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('SUM(distance)');
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle adhoc breakpoint metric', () => {
|
||||
const adhocMetric = {
|
||||
label: 'avg_speed',
|
||||
expressionType: 'SQL' as const,
|
||||
sqlExpression: 'AVG(speed_mph)',
|
||||
};
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
breakpoint_metric: adhocMetric,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContainEqual(adhocMetric);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle missing breakpoint_metric', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
test('Path buildQuery should handle line_width and breakpoint_metrics together together', () => {
|
||||
const formData: DeckPathFormData = {
|
||||
...baseFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
breakpoint_metric: 'AVG(speed)',
|
||||
js_columns: ['color'],
|
||||
tooltip_contents: ['name'],
|
||||
row_limit: 500,
|
||||
};
|
||||
|
||||
const queryContext = buildQuery(formData);
|
||||
const [query] = queryContext.queries;
|
||||
|
||||
expect(query.metrics).toContain('SUM(distance)');
|
||||
expect(query.metrics).toContain('AVG(speed)');
|
||||
expect(query.columns).toContain('color');
|
||||
expect(query.columns).toContain('name');
|
||||
expect(query.row_limit).toBe(500);
|
||||
});
|
||||
@@ -19,10 +19,13 @@
|
||||
import {
|
||||
buildQueryContext,
|
||||
ensureIsArray,
|
||||
getMetricLabel,
|
||||
SqlaFormData,
|
||||
QueryFormColumn,
|
||||
QueryFormMetric,
|
||||
} from '@superset-ui/core';
|
||||
import { addNullFilters, addTooltipColumnsToQuery } from '../buildQueryUtils';
|
||||
import { isMetricValue } from '../utils/metricUtils';
|
||||
|
||||
export interface DeckPathFormData extends SqlaFormData {
|
||||
line_column?: string;
|
||||
@@ -32,10 +35,26 @@ export interface DeckPathFormData extends SqlaFormData {
|
||||
js_columns?: string[];
|
||||
tooltip_contents?: unknown[];
|
||||
tooltip_template?: string;
|
||||
line_width?:
|
||||
| string
|
||||
| { type?: 'fix' | 'metric'; value?: QueryFormMetric | number };
|
||||
line_width_multiplier?: number;
|
||||
min_width?: number;
|
||||
max_width?: number;
|
||||
dimension?: string;
|
||||
breakpoint_metric?: QueryFormMetric;
|
||||
}
|
||||
|
||||
export default function buildQuery(formData: DeckPathFormData) {
|
||||
const { line_column, metric, js_columns, tooltip_contents } = formData;
|
||||
const {
|
||||
line_column,
|
||||
metric,
|
||||
js_columns,
|
||||
tooltip_contents,
|
||||
line_width,
|
||||
dimension,
|
||||
breakpoint_metric,
|
||||
} = formData;
|
||||
|
||||
if (!line_column) {
|
||||
throw new Error('Line column is required for Path charts');
|
||||
@@ -46,7 +65,7 @@ export default function buildQuery(formData: DeckPathFormData) {
|
||||
const columns = ensureIsArray(
|
||||
baseQueryObject.columns || [],
|
||||
) as QueryFormColumn[];
|
||||
const metrics = ensureIsArray(baseQueryObject.metrics || []);
|
||||
let metrics = ensureIsArray(baseQueryObject.metrics || []);
|
||||
const groupby = ensureIsArray(
|
||||
baseQueryObject.groupby || [],
|
||||
) as QueryFormColumn[];
|
||||
@@ -63,6 +82,49 @@ export default function buildQuery(formData: DeckPathFormData) {
|
||||
columns.push(line_column);
|
||||
}
|
||||
|
||||
// Include dimension column for categorical color mode
|
||||
if (dimension && !columns.includes(dimension)) {
|
||||
columns.push(dimension);
|
||||
}
|
||||
|
||||
// Add metric if line_width is a metric type
|
||||
const isMetric = isMetricValue(line_width);
|
||||
const rawWidthValue =
|
||||
typeof line_width === 'string'
|
||||
? line_width
|
||||
: typeof line_width === 'number'
|
||||
? undefined
|
||||
: line_width?.value;
|
||||
const widthMetric: QueryFormMetric | null =
|
||||
isMetric &&
|
||||
rawWidthValue !== undefined &&
|
||||
typeof rawWidthValue !== 'number'
|
||||
? (rawWidthValue as QueryFormMetric)
|
||||
: null;
|
||||
|
||||
// ensure metric is not added to metric array twice
|
||||
const existingLabels = new Set(metrics.map(m => getMetricLabel(m)));
|
||||
if (widthMetric && !existingLabels.has(getMetricLabel(widthMetric))) {
|
||||
metrics = [...metrics, widthMetric];
|
||||
}
|
||||
|
||||
// ensure line_column is in groupby when aggregating by width metric
|
||||
if (widthMetric && !groupby.includes(line_column)) {
|
||||
groupby.push(line_column);
|
||||
}
|
||||
|
||||
if (breakpoint_metric) {
|
||||
const breakpointLabel = getMetricLabel(breakpoint_metric);
|
||||
const currentLabels = new Set(metrics.map(m => getMetricLabel(m)));
|
||||
if (!currentLabels.has(breakpointLabel)) {
|
||||
metrics = [...metrics, breakpoint_metric];
|
||||
}
|
||||
// ensure line_column is in groupby when aggregating
|
||||
if (!groupby.includes(line_column)) {
|
||||
groupby.push(line_column);
|
||||
}
|
||||
}
|
||||
|
||||
jsColumns.forEach(col => {
|
||||
if (!columns.includes(col) && !groupby.includes(col)) {
|
||||
columns.push(col);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 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 {
|
||||
ControlPanelSectionConfig,
|
||||
ControlSetRow,
|
||||
ControlSetItem,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
test('controlPanel should have Path Size section', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
expect(pathSizeSection).toBeDefined();
|
||||
expect(pathSizeSection?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
test('controlPanel should include pathLineWidthFixedOrMetric control', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const control = pathSizeSection?.controlSetRows
|
||||
.flat()
|
||||
.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width',
|
||||
) as any;
|
||||
|
||||
expect(control).toBeDefined();
|
||||
expect(control.config.type).toBe('FixedOrMetricControl');
|
||||
expect(control.config.default).toEqual({ type: 'fix', value: 1 });
|
||||
});
|
||||
|
||||
test('controlPanel should include line_width_unit control with pixels as default', () => {
|
||||
const pathSizeSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const lineWidthRow = pathSizeSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_unit',
|
||||
),
|
||||
);
|
||||
|
||||
const lineWidthControl = lineWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_unit',
|
||||
) as any;
|
||||
|
||||
expect(lineWidthControl).toBeDefined();
|
||||
expect(lineWidthControl?.config?.default).toBe('pixels');
|
||||
});
|
||||
|
||||
test('controlPanel should include min_width control with default of 1', () => {
|
||||
const minWidthSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const minWidthRow = minWidthSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'min_width',
|
||||
),
|
||||
);
|
||||
|
||||
const minWidthControl = minWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'min_width',
|
||||
) as any;
|
||||
|
||||
expect(minWidthControl).toBeDefined();
|
||||
expect(minWidthControl?.config?.default).toBe(1);
|
||||
});
|
||||
|
||||
test('controlPanel should include max_width control with default of 20', () => {
|
||||
const maxWidthSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const maxWidthRow = maxWidthSection?.controlSetRows.find(
|
||||
(row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'max_width',
|
||||
),
|
||||
);
|
||||
|
||||
const maxWidthControl = maxWidthRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'max_width',
|
||||
) as any;
|
||||
|
||||
expect(maxWidthControl).toBeDefined();
|
||||
expect(maxWidthControl?.config?.default).toBe(20);
|
||||
});
|
||||
|
||||
test('controlPanel should include line_width_multiplier control with default of 1', () => {
|
||||
const lineWidthMultiplierSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Size',
|
||||
);
|
||||
|
||||
const lineWidthMultiplierRow =
|
||||
lineWidthMultiplierSection?.controlSetRows.find((row: ControlSetRow) =>
|
||||
row.some(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_multiplier',
|
||||
),
|
||||
);
|
||||
|
||||
const lineWidthMultiplierControl = lineWidthMultiplierRow?.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'line_width_multiplier',
|
||||
) as any;
|
||||
|
||||
expect(lineWidthMultiplierControl).toBeDefined();
|
||||
expect(lineWidthMultiplierControl?.config?.default).toBe(1);
|
||||
});
|
||||
|
||||
test('controlPanel should have Path Color section', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
expect(pathColorSection).toBeDefined();
|
||||
expect(pathColorSection?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
test('controlPanel should have Path Color section with color scheme controls', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
const controlNames = pathColorSection?.controlSetRows
|
||||
.flat()
|
||||
.filter(
|
||||
(control: ControlSetItem) =>
|
||||
control && typeof control === 'object' && 'name' in control,
|
||||
)
|
||||
.map((control: any) => control.name);
|
||||
|
||||
expect(controlNames).toContain('color_scheme_type');
|
||||
expect(controlNames).toContain('color_picker');
|
||||
expect(controlNames).toContain('dimension');
|
||||
expect(controlNames).toContain('color_scheme');
|
||||
expect(controlNames).toContain('breakpoint_metric');
|
||||
expect(controlNames).toContain('default_breakpoint_color');
|
||||
expect(controlNames).toContain('color_breakpoints');
|
||||
});
|
||||
|
||||
test('color_scheme_type should default to fixed_color', () => {
|
||||
const pathColorSection = controlPanel.controlPanelSections.find(
|
||||
(
|
||||
section: ControlPanelSectionConfig | null,
|
||||
): section is ControlPanelSectionConfig =>
|
||||
section != null && section.label === 'Path Color',
|
||||
);
|
||||
|
||||
const schemeTypeControl = pathColorSection?.controlSetRows
|
||||
.flat()
|
||||
.find(
|
||||
(control: ControlSetItem) =>
|
||||
control &&
|
||||
typeof control === 'object' &&
|
||||
'name' in control &&
|
||||
control.name === 'color_scheme_type',
|
||||
) as any;
|
||||
|
||||
expect(schemeTypeControl).toBeDefined();
|
||||
expect(schemeTypeControl?.config?.default).toBe('fixed_color');
|
||||
});
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
jsTooltip,
|
||||
jsOnclickHref,
|
||||
viewport,
|
||||
lineWidth,
|
||||
lineType,
|
||||
reverseLongLat,
|
||||
mapboxStyle,
|
||||
@@ -34,8 +33,12 @@ import {
|
||||
mapProvider,
|
||||
tooltipContents,
|
||||
tooltipTemplate,
|
||||
pathLineWidthFixedOrMetric,
|
||||
generateDeckGLColorSchemeControls,
|
||||
} from '../../utilities/Shared_DeckGL';
|
||||
import { dndLineColumn } from '../../utilities/sharedDndControls';
|
||||
import { validateNonEmpty } from '@superset-ui/core';
|
||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
@@ -71,25 +74,83 @@ const config: ControlPanelConfig = {
|
||||
[mapboxStyle],
|
||||
[maplibreStyle],
|
||||
[viewport],
|
||||
['color_picker'],
|
||||
[lineWidth],
|
||||
[reverseLongLat],
|
||||
[autozoom],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Path Size'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[pathLineWidthFixedOrMetric],
|
||||
[
|
||||
{
|
||||
name: 'line_width_unit',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Line width unit'),
|
||||
default: 'meters',
|
||||
default: 'pixels',
|
||||
choices: [
|
||||
['meters', t('meters')],
|
||||
['pixels', t('pixels')],
|
||||
],
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[reverseLongLat],
|
||||
[autozoom],
|
||||
[
|
||||
{
|
||||
name: 'min_width',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Minimum Width'),
|
||||
isFloat: true,
|
||||
validators: [validateNonEmpty],
|
||||
renderTrigger: true,
|
||||
default: 1,
|
||||
description: t(
|
||||
'Minimum width size of the path, in pixels or meters.',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'max_width',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Maximum Width'),
|
||||
isFloat: true,
|
||||
validators: [validateNonEmpty],
|
||||
renderTrigger: true,
|
||||
default: 20,
|
||||
description: t(
|
||||
'Maximum width size of the path, in pixels or meters.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'line_width_multiplier',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Width scale multiplier'),
|
||||
renderTrigger: true,
|
||||
isFloat: true,
|
||||
default: 1,
|
||||
description: t(
|
||||
'Scale factor applied to metric-driven line widths',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Path Color'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
...generateDeckGLColorSchemeControls({
|
||||
defaultSchemeType: COLOR_SCHEME_TYPES.fixed_color,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 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 { ChartProps, DatasourceType } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
|
||||
interface PathFeature {
|
||||
path: [number, number][];
|
||||
width?: number;
|
||||
metric?: number;
|
||||
cat_color?: string;
|
||||
extraProps?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const samplePath1 = JSON.stringify([
|
||||
[-122.4, 37.8],
|
||||
[-122.3, 37.9],
|
||||
]);
|
||||
const samplePath2 = JSON.stringify([
|
||||
[-122.5, 37.7],
|
||||
[-122.4, 37.8],
|
||||
]);
|
||||
const samplePath3 = JSON.stringify([
|
||||
[-122.6, 37.6],
|
||||
[-122.5, 37.7],
|
||||
]);
|
||||
|
||||
const mockChartProps: Partial<ChartProps> = {
|
||||
rawFormData: {
|
||||
line_column: 'path_json',
|
||||
line_type: 'json',
|
||||
viewport: {},
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
path_json: samplePath1,
|
||||
'AVG(weight)': 100,
|
||||
'SUM(distance)': 500,
|
||||
route_type: 'express',
|
||||
},
|
||||
{
|
||||
path_json: samplePath2,
|
||||
'AVG(weight)': 200,
|
||||
'SUM(distance)': 1000,
|
||||
route_type: 'local',
|
||||
},
|
||||
{
|
||||
path_json: samplePath3,
|
||||
'AVG(weight)': 50,
|
||||
'SUM(distance)': 250,
|
||||
route_type: 'express',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
datasource: {
|
||||
type: DatasourceType.Table,
|
||||
id: 1,
|
||||
name: 'test_datasource',
|
||||
columns: [],
|
||||
metrics: [],
|
||||
},
|
||||
height: 400,
|
||||
width: 600,
|
||||
hooks: {},
|
||||
filterState: {},
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
test('Path transformProps should parse JSON paths correctly', () => {
|
||||
const result = transformProps(mockChartProps as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(3);
|
||||
features.forEach(f => {
|
||||
expect(f.path).toBeDefined();
|
||||
expect(Array.isArray(f.path)).toBe(true);
|
||||
expect(f.path.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should handle empty records', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
queriesData: [{ data: [] }],
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Path transformProps should handle missing line_column', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_column: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Path transformProps should handle invalid JSON path data', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ path_json: 'not valid json' }, { path_json: '12345' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(2);
|
||||
// Should not throw, paths should be empty arrays
|
||||
features.forEach(f => {
|
||||
expect(Array.isArray(f.path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use fixed width value when line_width type is "fix"', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features.length).toBe(3);
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use fixed width with string value', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'fix',
|
||||
value: '10',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should not set width when line_width is missing', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
features.forEach(f => {
|
||||
expect(f.width).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('Path transformProps should use metric value for width when line_width type is "metric"', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'AVG(weight)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(features[0]?.width).toBe(50);
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric from breakpoint_metric', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
const metrics = features
|
||||
.map(f => f.metric)
|
||||
.filter((m): m is number => m !== undefined)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
expect(metrics).toEqual([50, 100, 200]);
|
||||
});
|
||||
|
||||
test('Path transformProps should fall back to base metric when breakpoint_metric is missing', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: 'AVG(weight)',
|
||||
breakpoint_metric: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
const metrics = features
|
||||
.map(f => f.metric)
|
||||
.filter((m): m is number => m !== undefined)
|
||||
.sort((a, b) => a - b);
|
||||
expect(metrics).toEqual([50, 100, 200]);
|
||||
});
|
||||
|
||||
test('Path transformProps should include both breakpoint_metric and width metrics if they are different', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(result.payload.data.metricLabels).toEqual([
|
||||
'AVG(weight)',
|
||||
'SUM(distance)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Path transformProps should not include both breakpoint_metric and width metrics if they are the same', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'SUM(distance)',
|
||||
line_width: {
|
||||
type: 'metric',
|
||||
value: 'SUM(distance)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toEqual(['SUM(distance)']);
|
||||
});
|
||||
|
||||
test('Path transformProps should set cat_color from dimension column', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
dimension: 'route_type',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
const features = result.payload.data.features as PathFeature[];
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(features[0]?.cat_color).toBe('express');
|
||||
expect(features[1]?.cat_color).toBe('local');
|
||||
expect(features[2]?.cat_color).toBe('express');
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric labels when breakpoint_metric is set', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
breakpoint_metric: 'AVG(weight)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toContain('AVG(weight)');
|
||||
});
|
||||
|
||||
test('Path transformProps should include metric labels from base metric', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: 'SUM(distance)',
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toContain('SUM(distance)');
|
||||
});
|
||||
|
||||
test('Path transformProps should have empty metric labels when no metric is set', () => {
|
||||
const props = {
|
||||
...mockChartProps,
|
||||
rawFormData: {
|
||||
...mockChartProps.rawFormData,
|
||||
metric: undefined,
|
||||
breakpoint_metric: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformProps(props as ChartProps);
|
||||
|
||||
expect(result.payload.data.metricLabels).toEqual([]);
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, DTTM_ALIAS } from '@superset-ui/core';
|
||||
import { ChartProps, DTTM_ALIAS, getMetricLabel } from '@superset-ui/core';
|
||||
import { addJsColumnsToExtraProps, DataRecord } from '../spatialUtils';
|
||||
import {
|
||||
createBaseTransformResult,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
addPropertiesToFeature,
|
||||
} from '../transformUtils';
|
||||
import { DeckPathFormData } from './buildQuery';
|
||||
import { isFixedValue, getFixedValue } from '../utils/metricUtils';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -48,6 +49,8 @@ interface PathFeature {
|
||||
path: [number, number][];
|
||||
metric?: number;
|
||||
timestamp?: unknown;
|
||||
width?: number;
|
||||
cat_color?: string;
|
||||
extraProps?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -91,6 +94,9 @@ function processPathData(
|
||||
reverseLongLat: boolean = false,
|
||||
metricLabel?: string,
|
||||
jsColumns?: string[],
|
||||
widthMetricLabel?: string,
|
||||
fixedWidthValue?: number | string | null,
|
||||
categoryColumn?: string,
|
||||
): PathFeature[] {
|
||||
if (!records.length || !lineColumn) {
|
||||
return [];
|
||||
@@ -103,6 +109,8 @@ function processPathData(
|
||||
'timestamp',
|
||||
DTTM_ALIAS,
|
||||
metricLabel,
|
||||
widthMetricLabel,
|
||||
categoryColumn,
|
||||
...(jsColumns || []),
|
||||
].filter(Boolean) as string[],
|
||||
);
|
||||
@@ -130,6 +138,24 @@ function processPathData(
|
||||
feature.metric = metricValue;
|
||||
}
|
||||
}
|
||||
// Set width from metric or fixed value
|
||||
if (fixedWidthValue != null) {
|
||||
// Use fixed width
|
||||
const parsedFixedWidth = parseMetricValue(fixedWidthValue);
|
||||
if (parsedFixedWidth !== undefined) {
|
||||
feature.width = parsedFixedWidth;
|
||||
}
|
||||
} else if (widthMetricLabel && record[widthMetricLabel] != null) {
|
||||
// Use metric value for width
|
||||
const widthValue = parseMetricValue(record[widthMetricLabel]);
|
||||
if (widthValue !== undefined) {
|
||||
feature.width = widthValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (categoryColumn && record[categoryColumn] != null) {
|
||||
feature.cat_color = String(record[categoryColumn]);
|
||||
}
|
||||
|
||||
feature = addJsColumnsToExtraProps(feature, record, jsColumns);
|
||||
feature = addPropertiesToFeature(feature, record, excludeKeys);
|
||||
@@ -143,11 +169,37 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
line_column,
|
||||
line_type = 'json',
|
||||
metric,
|
||||
line_width,
|
||||
dimension,
|
||||
reverse_long_lat = false,
|
||||
js_columns,
|
||||
breakpoint_metric,
|
||||
} = formData as DeckPathTransformPropsFormData;
|
||||
|
||||
const metricLabel = getMetricLabelFromFormData(metric);
|
||||
// Check so legacy values still work
|
||||
const fixedWidthValue =
|
||||
typeof line_width === 'number'
|
||||
? line_width
|
||||
: isFixedValue(line_width)
|
||||
? getFixedValue(line_width)
|
||||
: undefined;
|
||||
|
||||
const widthMetricLabel = getMetricLabelFromFormData(line_width);
|
||||
|
||||
const breakpointMetricLabel = breakpoint_metric
|
||||
? getMetricLabel(breakpoint_metric)
|
||||
: undefined;
|
||||
const baseMetricLabel = getMetricLabelFromFormData(metric);
|
||||
const metricLabel = breakpointMetricLabel || baseMetricLabel;
|
||||
|
||||
// ensure all metric labels are included
|
||||
const metricLabels = [
|
||||
...(metricLabel ? [metricLabel] : []),
|
||||
...(widthMetricLabel && widthMetricLabel !== metricLabel
|
||||
? [widthMetricLabel]
|
||||
: []),
|
||||
];
|
||||
|
||||
const records = getRecordsFromQuery(chartProps.queriesData);
|
||||
const features = processPathData(
|
||||
records,
|
||||
@@ -156,11 +208,10 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
reverse_long_lat,
|
||||
metricLabel,
|
||||
js_columns,
|
||||
widthMetricLabel,
|
||||
fixedWidthValue,
|
||||
dimension,
|
||||
).reverse();
|
||||
|
||||
return createBaseTransformResult(
|
||||
chartProps,
|
||||
features,
|
||||
metricLabel ? [metricLabel] : [],
|
||||
);
|
||||
return createBaseTransformResult(chartProps, features, metricLabels);
|
||||
}
|
||||
|
||||
@@ -285,6 +285,22 @@ export const lineWidth = {
|
||||
},
|
||||
};
|
||||
|
||||
// created new const so as not to break lineWidth usages in other charts
|
||||
export const pathLineWidthFixedOrMetric = {
|
||||
name: 'line_width',
|
||||
config: {
|
||||
type: 'FixedOrMetricControl', // using existing type
|
||||
label: t('Line width'),
|
||||
default: { type: 'fix', value: 1 }, // kept same default as before
|
||||
description: t(
|
||||
'The width of the lines as either a fixed value or variable width based on a metric.',
|
||||
),
|
||||
mapStateToProps: (state: ControlPanelState) => ({
|
||||
datasource: state.datasource,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const fillColorPicker: CustomControlItem = {
|
||||
name: 'fill_color_picker',
|
||||
config: {
|
||||
@@ -673,6 +689,24 @@ export const deckGLColorBreakpointsSelect: CustomControlItem = {
|
||||
},
|
||||
};
|
||||
|
||||
export const deckGLBreakpointMetric: CustomControlItem = {
|
||||
name: 'breakpoint_metric',
|
||||
config: {
|
||||
...sharedControls.metric,
|
||||
label: t('Breakpoint Metric'),
|
||||
default: null,
|
||||
validators: [],
|
||||
description: t(
|
||||
'Select the metric used to determine which color breakpoint range each path falls into.',
|
||||
),
|
||||
// mapStateToProps: (state: ControlPanelState) => ({
|
||||
// datasource: state.datasource,
|
||||
// }),
|
||||
visibility: ({ controls }: { controls: any }) =>
|
||||
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
|
||||
},
|
||||
};
|
||||
|
||||
export const breakpointsDefaultColor: CustomControlItem = {
|
||||
name: 'default_breakpoint_color',
|
||||
config: {
|
||||
@@ -725,6 +759,7 @@ export const generateDeckGLColorSchemeControls = ({
|
||||
[deckGLFixedColor],
|
||||
disableCategoricalColumn ? [] : [deckGLCategoricalColor],
|
||||
[deckGLCategoricalColorSchemeSelect],
|
||||
[deckGLBreakpointMetric],
|
||||
[breakpointsDefaultColor],
|
||||
[deckGLColorBreakpointsSelect],
|
||||
];
|
||||
|
||||
@@ -97,7 +97,7 @@ export function createWrapper(options?: Options) {
|
||||
}
|
||||
|
||||
if (useDnd) {
|
||||
// @ts-expect-error react-dnd types not updated for React 18
|
||||
// @ts-ignore react-dnd's DndProviderProps omits `children` under React 18 types
|
||||
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1671,7 +1671,7 @@ export interface VizOptions {
|
||||
|
||||
export function createDatasource(
|
||||
vizOptions: VizOptions,
|
||||
): SqlLabThunkAction<Promise<unknown>> {
|
||||
): SqlLabThunkAction<Promise<{ id: number }>> {
|
||||
return (dispatch: AppDispatch) => {
|
||||
dispatch(createDatasourceStarted());
|
||||
const { dbId, catalog, schema, datasourceName, sql, templateParams } =
|
||||
@@ -1691,9 +1691,10 @@ export function createDatasource(
|
||||
}),
|
||||
})
|
||||
.then(({ json }) => {
|
||||
dispatch(createDatasourceSuccess(json as { id: number }));
|
||||
const result = json as { id: number };
|
||||
dispatch(createDatasourceSuccess(result));
|
||||
|
||||
return Promise.resolve(json);
|
||||
return result;
|
||||
})
|
||||
.catch(error => {
|
||||
getClientErrorObject(error).then(e => {
|
||||
@@ -1712,7 +1713,7 @@ export function createDatasource(
|
||||
|
||||
export function createCtasDatasource(
|
||||
vizOptions: Record<string, unknown>,
|
||||
): SqlLabThunkAction<Promise<{ id: number }>> {
|
||||
): SqlLabThunkAction<Promise<{ table_id: number }>> {
|
||||
return (dispatch: AppDispatch) => {
|
||||
dispatch(createDatasourceStarted());
|
||||
return SupersetClient.post({
|
||||
@@ -1720,9 +1721,14 @@ export function createCtasDatasource(
|
||||
jsonPayload: vizOptions,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
dispatch(createDatasourceSuccess(json.result));
|
||||
const result = json.result as { table_id: number };
|
||||
// The endpoint's `result.table_id` IS the dataset id; normalize so
|
||||
// createDatasourceSuccess's `${data.id}__table` resolves correctly.
|
||||
// Without this, the CTAS Explore button silently produced
|
||||
// `"undefined__table"` because `result.id` doesn't exist.
|
||||
dispatch(createDatasourceSuccess({ id: result.table_id }));
|
||||
|
||||
return json.result;
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
const errorMsg = t('An error occurred while creating the data source');
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
import { useRef, useEffect, FC, useMemo } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import {
|
||||
SqlLabRootState,
|
||||
@@ -86,7 +87,7 @@ const EditorAutoSync: FC = () => {
|
||||
const editorTabLastUpdatedAt = useSelector<SqlLabRootState, number>(
|
||||
state => state.sqlLab.editorTabLastUpdatedAt,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const lastSavedTimestampRef = useRef<number>(editorTabLastUpdatedAt);
|
||||
|
||||
const currentQueryEditorId = useSelector<SqlLabRootState, string>(
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { usePrevious } from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Global } from '@emotion/react';
|
||||
@@ -136,7 +137,7 @@ const EditorWrapper = ({
|
||||
height,
|
||||
hotkeys,
|
||||
}: EditorWrapperProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'id',
|
||||
'dbId',
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useStore } from 'react-redux';
|
||||
import { useStore } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry } from '@superset-ui/core';
|
||||
|
||||
@@ -68,7 +69,7 @@ export function useKeywords(
|
||||
catalog,
|
||||
schema,
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const hasFetchedKeywords = useRef(false);
|
||||
// skipFetch is used to prevent re-evaluating memoized keywords
|
||||
// due to updated api results by skip flag
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { JsonObject, VizType } from '@superset-ui/core';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import {
|
||||
createCtasDatasource,
|
||||
addInfoToast,
|
||||
@@ -45,7 +46,7 @@ const ExploreCtasResultsButton = ({
|
||||
const errorMessage = useSelector(
|
||||
(state: SqlLabRootState) => state.sqlLab.errorMessage,
|
||||
);
|
||||
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const buildVizOptions = {
|
||||
table_name: table,
|
||||
@@ -56,7 +57,7 @@ const ExploreCtasResultsButton = ({
|
||||
|
||||
const visualize = () => {
|
||||
dispatch(createCtasDatasource(buildVizOptions))
|
||||
.then((data: { table_id: number }) => {
|
||||
.then(data => {
|
||||
const formData = {
|
||||
datasource: `${data.table_id}__table`,
|
||||
metrics: ['count'],
|
||||
|
||||
@@ -47,6 +47,13 @@ const Title = styled.h4`
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
`;
|
||||
|
||||
const StyledTabs = styled(Tabs)`
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * -8}px;
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const shrinkSql = (sql: string, maxLines: number, maxWidth: number) => {
|
||||
const ssql = sql || '';
|
||||
let lines = ssql.split('\n');
|
||||
@@ -94,7 +101,7 @@ function HighlightSqlModal({ rawSql, sql }: HighlightedSqlModalTypes) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
<StyledTabs
|
||||
defaultActiveKey="executed"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import URI from 'urijs';
|
||||
import { pick } from 'lodash';
|
||||
import { useComponentDidUpdate } from '@superset-ui/core';
|
||||
@@ -49,7 +50,7 @@ const PopEditorTab: React.FC<{ children?: React.ReactNode }> = ({
|
||||
({ sqlLab: { tabHistory } }) => tabHistory.slice(-1)[0],
|
||||
);
|
||||
const [updatedUrl, setUpdatedUrl] = useState<string>(SQL_LAB_URL);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
useComponentDidUpdate(() => {
|
||||
setQueryEditorId(assigned => assigned ?? activeQueryEditorId);
|
||||
if (activeQueryEditorId) {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { isObject } from 'lodash';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
@@ -82,7 +83,7 @@ function QueryAutoRefresh({
|
||||
.map(({ id }) => id),
|
||||
),
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const checkForRefresh = () => {
|
||||
const shouldRequestChecking = shouldCheckForQueries(queries);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Button } from '@superset-ui/core/components';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
@@ -75,7 +75,7 @@ const QueryLimitSelect = ({
|
||||
maxRow,
|
||||
defaultQueryLimit,
|
||||
}: QueryLimitSelectProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const queryEditor = useQueryEditor(queryEditorId, ['id', 'queryLimit']);
|
||||
const queryLimit = queryEditor.queryLimit || defaultQueryLimit;
|
||||
|
||||
@@ -30,7 +30,8 @@ import ProgressBar from '@superset-ui/core/components/ProgressBar';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { QueryResponse, QueryState } from '@superset-ui/core';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
|
||||
import {
|
||||
queryEditorSetSql,
|
||||
@@ -92,7 +93,7 @@ const QueryTable = ({
|
||||
latestQueryId,
|
||||
}: QueryTableProps) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const [selectedQuery, setSelectedQuery] = useState<QueryResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { pick } from 'lodash';
|
||||
import {
|
||||
@@ -231,7 +232,7 @@ const ResultSet = ({
|
||||
canCopyClipboardSqlLab: canCopyClipboard,
|
||||
} = usePermissions();
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
|
||||
const { showConfirm, ConfirmModal } = useConfirmModal();
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import * as reactRedux from 'react-redux';
|
||||
import { act } from 'react';
|
||||
import { act, type ComponentProps } from 'react';
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
@@ -40,6 +39,19 @@ const mockedProps = {
|
||||
datasource: testQuery,
|
||||
};
|
||||
|
||||
// Render with the SqlLab user fixture preloaded into the mock store so the
|
||||
// component's useSelector(state => state.user) returns a useful value.
|
||||
// Previously this test used jest.spyOn(reactRedux, 'useSelector') to inject
|
||||
// the user directly, which can't intercept calls routed through the typed
|
||||
// useAppSelector hook.
|
||||
const renderModal = (
|
||||
props: Partial<ComponentProps<typeof SaveDatasetModal>> = {},
|
||||
) =>
|
||||
render(<SaveDatasetModal {...mockedProps} {...props} />, {
|
||||
useRedux: true,
|
||||
initialState: { user },
|
||||
});
|
||||
|
||||
fetchMock.get('glob:*/api/v1/dataset/?*', {
|
||||
result: mockdatasets,
|
||||
dataset_count: 3,
|
||||
@@ -47,17 +59,17 @@ fetchMock.get('glob:*/api/v1/dataset/?*', {
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
|
||||
// Mock the user
|
||||
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
|
||||
beforeEach(() => {
|
||||
useSelectorMock.mockClear();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock the createDatasource action
|
||||
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
|
||||
// Mock createDatasource to return a thunk that resolves with the dataset's
|
||||
// new id. The test's mock store includes redux-thunk middleware (from RTK's
|
||||
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
|
||||
// the thunk and the production code's .then((data) => clearDatasetCache(data.id))
|
||||
// chain receives `{ id: 123 }`. Individual tests can override per-call as needed.
|
||||
jest.mock('src/SqlLab/actions/sqlLab', () => ({
|
||||
createDatasource: jest.fn(),
|
||||
createDatasource: jest.fn(() => () => Promise.resolve({ id: 123 })),
|
||||
}));
|
||||
jest.mock('src/explore/exploreUtils/formData', () => ({
|
||||
postFormData: jest.fn(),
|
||||
@@ -70,7 +82,7 @@ jest.mock('src/utils/cachedSupersetGet', () => ({
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('SaveDatasetModal', () => {
|
||||
test('renders a "Save as new" field', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const saveRadioBtn = screen.getByRole('radio', {
|
||||
name: /save as new/i,
|
||||
@@ -87,7 +99,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders an "Overwrite existing" field', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
name: /overwrite existing/i,
|
||||
@@ -103,20 +115,20 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders a close button', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a save button when "Save as new" is selected', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// "Save as new" is selected when the modal opens by default
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders an overwrite button when "Overwrite existing" is selected', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// Click the overwrite radio button to reveal the overwrite confirmation and back buttons
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -130,8 +142,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders the overwrite button as disabled until an existing dataset is selected', async () => {
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// Click the overwrite radio button
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -168,8 +179,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('renders a confirm overwrite screen when overwrite is clicked', async () => {
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
// Click the overwrite radio button
|
||||
const overwriteRadioBtn = screen.getByRole('radio', {
|
||||
@@ -215,11 +225,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('sends the schema when creating the dataset', async () => {
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -240,17 +246,9 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('sends the catalog when creating the dataset', async () => {
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
render(
|
||||
<SaveDatasetModal
|
||||
{...mockedProps}
|
||||
datasource={{ ...mockedProps.datasource, catalog: 'public' }}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
renderModal({
|
||||
datasource: { ...mockedProps.datasource, catalog: 'public' },
|
||||
});
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -271,7 +269,7 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
|
||||
test('does not renders a checkbox button when template processing is disabled', () => {
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -280,7 +278,7 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -289,15 +287,11 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -324,15 +318,11 @@ describe('SaveDatasetModal', () => {
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
@@ -393,19 +383,11 @@ describe('SaveDatasetModal', () => {
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12, _filters: 'foo' }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Check the "Include Template Parameters" checkbox
|
||||
@@ -443,19 +425,11 @@ describe('SaveDatasetModal', () => {
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
renderModal({
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Do NOT check the "Include Template Parameters" checkbox
|
||||
@@ -489,12 +463,9 @@ describe('SaveDatasetModal', () => {
|
||||
'postFormData',
|
||||
);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({ id: 123 });
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
postFormData.mockResolvedValue('chart_key_123');
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
|
||||
renderModal();
|
||||
|
||||
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
|
||||
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
|
||||
|
||||
@@ -34,7 +34,6 @@ import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
SupersetClient,
|
||||
JsonResponse,
|
||||
JsonObject,
|
||||
QueryResponse,
|
||||
QueryFormData,
|
||||
VizType,
|
||||
@@ -44,16 +43,14 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useAppDispatch, useAppSelector } from 'src/views/store';
|
||||
import rison from 'rison';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
|
||||
import {
|
||||
DatasetRadioState,
|
||||
EXPLORE_CHART_DEFAULT,
|
||||
DatasetOwner,
|
||||
SqlLabRootState,
|
||||
} from 'src/SqlLab/types';
|
||||
import { mountExploreUrl } from 'src/explore/exploreUtils';
|
||||
import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||
@@ -221,7 +218,7 @@ export const SaveDatasetModal = ({
|
||||
openWindow = true,
|
||||
formData = {},
|
||||
}: SaveDatasetModalProps) => {
|
||||
const defaultVizType = useSelector<SqlLabRootState, string>(
|
||||
const defaultVizType = useAppSelector(
|
||||
state => state.common?.conf?.DEFAULT_VIZ_TYPE || VizType.Table,
|
||||
);
|
||||
|
||||
@@ -240,8 +237,8 @@ export const SaveDatasetModal = ({
|
||||
>(undefined);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const user = useSelector<SqlLabRootState, User>(state => state.user);
|
||||
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
|
||||
const user = useAppSelector(state => state.user);
|
||||
const dispatch = useAppDispatch();
|
||||
const [includeTemplateParameters, setIncludeTemplateParameters] =
|
||||
useState(false);
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef, useCallback, useMemo } from 'react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -105,7 +106,7 @@ const SouthPane = ({
|
||||
const { id, tabViewId } = useQueryEditor(queryEditorId, ['tabViewId']);
|
||||
const editorId = tabViewId ?? id;
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const viewItems = views.getViews(ViewLocations.sqllab.panels) || [];
|
||||
const { offline, tables } = useSelector(
|
||||
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
|
||||
import type { editors } from '@apache-superset/core';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
@@ -237,7 +238,7 @@ const SqlEditor: FC<Props> = ({
|
||||
scheduleQueryWarning,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { database, latestQuery, currentQueryEditorId, hasSqlStatement } =
|
||||
useSelector<
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
|
||||
import { resetState } from 'src/SqlLab/actions/sqlLab';
|
||||
import {
|
||||
@@ -69,7 +69,7 @@ const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
|
||||
const { db, catalog, schema, onDbChange, onCatalogChange, onSchemaChange } =
|
||||
dbSelectorProps;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const shouldShowReset = window.location.search === '?reset=1';
|
||||
|
||||
// Modal state for Database/Catalog/Schema selector
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
import { useMemo, FC } from 'react';
|
||||
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { useSelector, shallowEqual } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { MenuDotsDropdown } from '@superset-ui/core/components';
|
||||
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -90,7 +91,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
||||
);
|
||||
const StatusIcon = queryState ? STATE_ICONS[queryState] : STATE_ICONS.running;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
bindActionCreators(
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useCallback, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import {
|
||||
@@ -41,7 +42,7 @@ export default function useDatabaseSelector(queryEditorId: string) {
|
||||
SqlLabRootState,
|
||||
SqlLabRootState['sqlLab']['databases']
|
||||
>(({ sqlLab }) => sqlLab.databases);
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'dbId',
|
||||
'catalog',
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
|
||||
import {
|
||||
ButtonGroup,
|
||||
@@ -75,7 +76,7 @@ const Fade = styled.div`
|
||||
const TableElement = ({ table, ...props }: TableElementProps) => {
|
||||
const { dbId, catalog, schema, name, expanded, id } = table;
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const {
|
||||
currentData: tableMetadata,
|
||||
isSuccess: isMetadataSuccess,
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
type ChangeEvent,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { useSelector, shallowEqual } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
@@ -163,7 +164,7 @@ const savePinnedSchemasToStorage = (
|
||||
};
|
||||
|
||||
const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const treeRef = useRef<TreeApi<TreeNodeData>>(null);
|
||||
const tables = useSelector(
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useReducer, useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
Table,
|
||||
@@ -130,7 +130,7 @@ const useTreeData = ({
|
||||
catalog,
|
||||
pinnedTables,
|
||||
}: UseTreeDataParams): UseTreeDataResult => {
|
||||
const reduxDispatch = useDispatch();
|
||||
const reduxDispatch = useAppDispatch();
|
||||
// Schema data from API
|
||||
const {
|
||||
currentData: schemaData,
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { type FC, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/views/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ClientErrorObject, getExtensionsRegistry } from '@superset-ui/core';
|
||||
@@ -110,7 +111,7 @@ const renderWell = (partitions: TableMetaData['partitions']) => {
|
||||
};
|
||||
|
||||
const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const [databaseName, backend, disableDataPreview] = useSelector<
|
||||
SqlLabRootState,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 React from 'react';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { views } from 'src/core';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import ChatbotMount from '.';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
});
|
||||
|
||||
test('renders nothing when no chatbot extension is registered', () => {
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the registered chatbot inside the fixed mount slot', () => {
|
||||
const provider = () => React.createElement('div', null, 'My Chatbot Bubble');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders only the first-to-register chatbot when several are installed', () => {
|
||||
const firstProvider = () => React.createElement('div', null, 'First Bubble');
|
||||
const secondProvider = () =>
|
||||
React.createElement('div', null, 'Second Bubble');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(screen.getByText('First Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a failing chatbot so it does not crash the host', () => {
|
||||
const FailingChatbot = () => {
|
||||
throw new Error('chatbot blew up');
|
||||
};
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
() => React.createElement(FailingChatbot),
|
||||
),
|
||||
);
|
||||
|
||||
// The host-owned error boundary catches the failure; render does not throw.
|
||||
expect(() => render(<ChatbotMount />)).not.toThrow();
|
||||
});
|
||||
84
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
84
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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 { useState, useEffect } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { getActiveChatbot } from 'src/core/chatbot';
|
||||
import { subscribeToLocation } from 'src/core/views';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
|
||||
const CHATBOT_EDGE_MARGIN = 24;
|
||||
|
||||
const ChatbotMount = () => {
|
||||
const theme = useTheme();
|
||||
const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null);
|
||||
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
||||
const [activeChatbot, setActiveChatbot] = useState(() =>
|
||||
getActiveChatbot(null, {}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
|
||||
.then(({ json }) => {
|
||||
if (cancelled) return;
|
||||
const id = json.result?.active_chatbot_id ?? null;
|
||||
const enabled: Record<string, boolean> = json.result?.enabled ?? {};
|
||||
setAdminSelectedId(id);
|
||||
setEnabledMap(enabled);
|
||||
setActiveChatbot(getActiveChatbot(id, enabled));
|
||||
})
|
||||
.catch(() => {
|
||||
// Settings fetch failure is non-fatal — fall back to first-to-register.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeToLocation(CHATBOT_LOCATION, () =>
|
||||
setActiveChatbot(getActiveChatbot(adminSelectedId, enabledMap)),
|
||||
),
|
||||
[adminSelectedId, enabledMap],
|
||||
);
|
||||
|
||||
if (!activeChatbot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="chatbot-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
right: ${CHATBOT_EDGE_MARGIN}px;
|
||||
bottom: ${CHATBOT_EDGE_MARGIN}px;
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
<ErrorBoundary>{activeChatbot.provider()}</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatbotMount;
|
||||
@@ -28,7 +28,7 @@ import fetchMock from 'fetch-mock';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import mockDatasource from 'spec/fixtures/mockDatasource';
|
||||
import React from 'react';
|
||||
import DatasourceModalComponent from '.';
|
||||
import DatasourceModalComponent, { buildExtraJsonObject } from '.';
|
||||
|
||||
// Cast to accept partial mock props in tests
|
||||
const DatasourceModal = DatasourceModalComponent as unknown as React.FC<
|
||||
@@ -309,3 +309,35 @@ describe('DatasourceModal', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExtraJsonObject', () => {
|
||||
test('returns "{}" for an item with no warning and no certification', () => {
|
||||
expect(buildExtraJsonObject({} as any)).toBe('{}');
|
||||
});
|
||||
|
||||
test('drops warning_markdown when its value is null', () => {
|
||||
expect(buildExtraJsonObject({ warning_markdown: null } as any)).toBe('{}');
|
||||
});
|
||||
|
||||
test('drops warning_markdown when its value is an empty string', () => {
|
||||
expect(buildExtraJsonObject({ warning_markdown: '' } as any)).toBe('{}');
|
||||
});
|
||||
|
||||
test('preserves a non-empty warning_markdown verbatim', () => {
|
||||
expect(buildExtraJsonObject({ warning_markdown: '⚠ caveat' } as any)).toBe(
|
||||
'{"warning_markdown":"⚠ caveat"}',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves certification and drops null warning_markdown', () => {
|
||||
expect(
|
||||
buildExtraJsonObject({
|
||||
certified_by: 'data-team',
|
||||
certification_details: 'verified',
|
||||
warning_markdown: null,
|
||||
} as any),
|
||||
).toBe(
|
||||
'{"certification":{"certified_by":"data-team","details":"verified"}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ const StyledDatasourceModal = styled(Modal)`
|
||||
}
|
||||
`;
|
||||
|
||||
function buildExtraJsonObject(
|
||||
export function buildExtraJsonObject(
|
||||
item: DatasetObject['metrics'][0] | DatasetObject['columns'][0],
|
||||
) {
|
||||
const certification =
|
||||
@@ -83,7 +83,7 @@ function buildExtraJsonObject(
|
||||
: undefined;
|
||||
return JSON.stringify({
|
||||
certification,
|
||||
warning_markdown: item?.warning_markdown,
|
||||
warning_markdown: item?.warning_markdown || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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 { createRef } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
selectOption,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { ListViewFilterOperator } from '../types';
|
||||
import UIFilters from './index';
|
||||
import SelectFilter from './Select';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
const mockUpdateFilterValue = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateFilterValue.mockClear();
|
||||
});
|
||||
|
||||
test('select filter with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Regression for sc-104554: the chart-list Owner filter renders options
|
||||
// with ReactNode labels (name + email). The value passed to
|
||||
// updateFilterValue is serialized into URL / filter state and re-used to
|
||||
// render the filter pill on return. It must carry the plain-text name
|
||||
// (from `title`) and not fall back to the numeric user id.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>John Doe</span>
|
||||
<span>john@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: ReactNodeLabel,
|
||||
value: 42,
|
||||
title: 'John Doe',
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('John Doe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'John Doe',
|
||||
value: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('select filter falls back to stringified value when no string label or title is available', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: <span>123</span>,
|
||||
value: 123,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Something',
|
||||
key: 'something',
|
||||
id: 'something',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('123', 'Something');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: '123',
|
||||
value: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with string label passes label through unchanged', async () => {
|
||||
// Happy-path coverage for the typeof-string branch in onChange, exercised
|
||||
// through the non-async Select wrapper (selects array, no fetchSelects).
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Status',
|
||||
key: 'status',
|
||||
id: 'status',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.Equals,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [
|
||||
{ label: 'Published', value: 7 },
|
||||
{ label: 'Draft', value: 8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Published', 'Status');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Published',
|
||||
value: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Parallel coverage to the AsyncSelect ReactNode-with-title test, against
|
||||
// the non-async Select wrapper. Guards against the two wrappers ever
|
||||
// diverging on antd's two-arg onChange shape.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>Jane Roe</span>
|
||||
<span>jane@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [{ label: ReactNodeLabel, value: 99, title: 'Jane Roe' }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Jane Roe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Jane Roe',
|
||||
value: 99,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('clearFilter notifies onSelect with undefined and isClear=true', () => {
|
||||
// The isClear flag is what allows the parent (Filters/index) to suppress
|
||||
// onFilterUpdate side-effects when the user clears the filter rather than
|
||||
// picking a new value. Lock that contract in.
|
||||
const mockOnSelect = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
|
||||
render(
|
||||
<SelectFilter
|
||||
Header="Owner"
|
||||
initialValue={{ label: 'John Doe', value: 42 }}
|
||||
onSelect={mockOnSelect}
|
||||
selects={[{ label: 'John Doe', value: 42, title: 'John Doe' }]}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
|
||||
ref.current?.clearFilter();
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
|
||||
test('rehydrates filter pill from initialValue with plain-string label', async () => {
|
||||
// The user-visible regression: after URL/state rehydration the filter pill
|
||||
// must render the human-readable name, not the numeric user id. The fix
|
||||
// ensures the persisted label is a string; this test asserts that string
|
||||
// is what surfaces in the rendered combobox selection.
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: jest.fn().mockResolvedValue({ data: [], totalCount: 0 }),
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owners',
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
value: { label: 'John Doe', value: 42 },
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -58,14 +58,22 @@ function SelectFilter(
|
||||
) {
|
||||
const [selectedOption, setSelectedOption] = useState(initialValue);
|
||||
|
||||
const onChange = (selected: SelectOption) => {
|
||||
const onChange = (selected: SelectOption, option?: SelectOption) => {
|
||||
// antd's `onChange` (with `labelInValue`) passes the `{label, value}`
|
||||
// labeled-value as the first arg and the full option (which carries
|
||||
// `title` and any other fields) as the second. Options may supply a
|
||||
// ReactNode label (e.g. OwnerSelectLabel for the chart list Owner
|
||||
// filter). Since this object is serialized into the URL and rehydrated
|
||||
// as the filter pill on return, we need a plain string. Prefer `title`
|
||||
// (set by callers to the human-readable name) before falling back to
|
||||
// the value.
|
||||
onSelect(
|
||||
selected
|
||||
? {
|
||||
label:
|
||||
typeof selected.label === 'string'
|
||||
? selected.label
|
||||
: String(selected.value),
|
||||
: (option?.title ?? String(selected.value)),
|
||||
value: selected.value,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface SortColumn {
|
||||
export interface SelectOption {
|
||||
label: ReactNode;
|
||||
value: any;
|
||||
// Plain-text representation of the option. Callers should set this when
|
||||
// `label` is a ReactNode so that the option can be serialized (e.g. into
|
||||
// URL filter state) without losing the human-readable name.
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
96
superset-frontend/src/core/chatbot/index.test.ts
Normal file
96
superset-frontend/src/core/chatbot/index.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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 React from 'react';
|
||||
import { views } from 'src/core/views';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getActiveChatbot } from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
});
|
||||
|
||||
test('getActiveChatbot returns undefined when no chatbot is registered', () => {
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot resolves the single registered chatbot', () => {
|
||||
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot();
|
||||
expect(active).toEqual({ id: 'superset.chatbot', provider });
|
||||
});
|
||||
|
||||
test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
|
||||
const firstProvider = () => React.createElement('div', null, 'First');
|
||||
const secondProvider = () => React.createElement('div', null, 'Second');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot();
|
||||
expect(active?.id).toBe('first.chatbot');
|
||||
expect(active?.provider).toBe(firstProvider);
|
||||
});
|
||||
|
||||
test('getActiveChatbot ignores views registered at other locations', () => {
|
||||
const provider = () => React.createElement('div', null, 'Panel');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'some.panel', name: 'Some Panel' },
|
||||
'sqllab.panels',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
|
||||
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||
const disposable = views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
);
|
||||
|
||||
expect(getActiveChatbot()?.id).toBe('superset.chatbot');
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
87
superset-frontend/src/core/chatbot/index.ts
Normal file
87
superset-frontend/src/core/chatbot/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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-internal resolver for the exclusive `superset.chatbot`
|
||||
* contribution area.
|
||||
*
|
||||
* `superset.chatbot` is a singleton contribution area: multiple chatbot
|
||||
* extensions may register a view there, but the host renders exactly one.
|
||||
* This module owns the host-side selection policy.
|
||||
*
|
||||
* This is host-internal infrastructure — it is NOT part of the public
|
||||
* `@apache-superset/core` API. Extensions register via the public
|
||||
* `views.registerView()`; only the host resolves which one is active.
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getRegisteredViewIds, getViewProvider } from 'src/core/views';
|
||||
|
||||
/**
|
||||
* The resolved active chatbot: a view id paired with its renderable provider.
|
||||
*/
|
||||
export interface ActiveChatbot {
|
||||
/** The registered view id of the selected chatbot. */
|
||||
id: string;
|
||||
/** The provider that renders the chatbot's React element. */
|
||||
provider: () => ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which single chatbot extension is currently active.
|
||||
*
|
||||
* Selection policy:
|
||||
* - If no chatbot is registered, returns `undefined` — the corner stays empty.
|
||||
* - Disabled chatbots (per `enabledMap`) are excluded before selection.
|
||||
* - If `adminSelectedId` matches an enabled registered chatbot, that one wins.
|
||||
* - Otherwise the first enabled chatbot in registration order is used as a fallback.
|
||||
*
|
||||
* @param adminSelectedId The id stored in the admin "Default chatbot" setting, if any.
|
||||
* @param enabledMap Per-extension enabled flags from the admin settings API.
|
||||
* @returns The active chatbot's id and provider, or `undefined` if none.
|
||||
*/
|
||||
export const getActiveChatbot = (
|
||||
adminSelectedId?: string | null,
|
||||
enabledMap?: Record<string, boolean>,
|
||||
): ActiveChatbot | undefined => {
|
||||
const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
|
||||
if (registeredIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidates = enabledMap
|
||||
? registeredIds.filter(id => enabledMap[id] !== false)
|
||||
: registeredIds;
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selectedId =
|
||||
adminSelectedId && candidates.includes(adminSelectedId)
|
||||
? adminSelectedId
|
||||
: candidates[0];
|
||||
|
||||
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { id: selectedId, provider };
|
||||
};
|
||||
90
superset-frontend/src/core/dashboard/index.ts
Normal file
90
superset-frontend/src/core/dashboard/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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 `dashboard` namespace.
|
||||
*
|
||||
* Wraps Redux dashboardInfo and dataMask state and normalizes them into the
|
||||
* stable `DashboardContext` contract. Extensions must not depend on the Redux
|
||||
* slice structure directly.
|
||||
*/
|
||||
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
|
||||
import {
|
||||
UPDATE_DATA_MASK,
|
||||
SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE,
|
||||
} from 'src/dataMask/actions';
|
||||
import { store, RootState } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import { createActionListener } from '../utils';
|
||||
|
||||
type DashboardContext = dashboardApi.DashboardContext;
|
||||
type FilterValue = dashboardApi.FilterValue;
|
||||
|
||||
function buildDashboardContext(): DashboardContext | undefined {
|
||||
const state = store.getState();
|
||||
const info = (state as any).dashboardInfo;
|
||||
if (!info?.id) return undefined;
|
||||
|
||||
const nativeFilters = (state as any).nativeFilters?.filters ?? {};
|
||||
const dataMask = (state as any).dataMask ?? {};
|
||||
|
||||
const filters: FilterValue[] = Object.entries(dataMask)
|
||||
.filter(([id, mask]: [string, any]) => {
|
||||
if (!(id in nativeFilters)) return false;
|
||||
const value = mask?.filterState?.value;
|
||||
return value !== null && value !== undefined;
|
||||
})
|
||||
.map(([id, mask]: [string, any]) => ({
|
||||
filterId: id,
|
||||
label: nativeFilters[id]?.name ?? id,
|
||||
value: mask.filterState.value,
|
||||
}));
|
||||
|
||||
return {
|
||||
dashboardId: info.id as number,
|
||||
title: info.dashboard_title ?? info.slug ?? String(info.id),
|
||||
filters,
|
||||
};
|
||||
}
|
||||
|
||||
const dashboardChangePredicate: AnyListenerPredicate<RootState> = action =>
|
||||
action.type === HYDRATE_DASHBOARD ||
|
||||
action.type === UPDATE_DATA_MASK ||
|
||||
action.type === SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE;
|
||||
|
||||
const getCurrentDashboard: typeof dashboardApi.getCurrentDashboard = () =>
|
||||
buildDashboardContext();
|
||||
|
||||
const onDidChangeDashboard: typeof dashboardApi.onDidChangeDashboard = (
|
||||
listener: (ctx: DashboardContext) => void,
|
||||
thisArgs?: any,
|
||||
) =>
|
||||
createActionListener<DashboardContext>(
|
||||
dashboardChangePredicate,
|
||||
listener,
|
||||
() => buildDashboardContext() ?? null,
|
||||
thisArgs,
|
||||
);
|
||||
|
||||
export const dashboard: typeof dashboardApi = {
|
||||
getCurrentDashboard,
|
||||
onDidChangeDashboard,
|
||||
};
|
||||
62
superset-frontend/src/core/dataset/index.ts
Normal file
62
superset-frontend/src/core/dataset/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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 `dataset` namespace.
|
||||
*
|
||||
* Dataset page components call `setCurrentDataset` to publish context as they
|
||||
* load. Extensions consume the stable `DatasetContext` contract; they are
|
||||
* isolated from the page's internal data-fetching implementation.
|
||||
*/
|
||||
|
||||
import type { dataset as datasetApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
|
||||
type DatasetContext = datasetApi.DatasetContext;
|
||||
|
||||
let currentDataset: DatasetContext | undefined;
|
||||
const listeners = new Set<(ctx: DatasetContext) => void>();
|
||||
|
||||
/**
|
||||
* Host-internal: called by the Dataset page when its entity loads or changes.
|
||||
* Not part of the public `@apache-superset/core` API.
|
||||
*/
|
||||
export const setCurrentDataset = (ctx: DatasetContext | undefined): void => {
|
||||
currentDataset = ctx;
|
||||
if (ctx) {
|
||||
listeners.forEach(fn => fn(ctx));
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentDataset: typeof datasetApi.getCurrentDataset = () =>
|
||||
currentDataset ? { ...currentDataset } : undefined;
|
||||
|
||||
const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = (
|
||||
listener: (ctx: DatasetContext) => void,
|
||||
thisArgs?: any,
|
||||
): Disposable => {
|
||||
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
listeners.add(bound);
|
||||
return new Disposable(() => listeners.delete(bound));
|
||||
};
|
||||
|
||||
export const dataset: typeof datasetApi = {
|
||||
getCurrentDataset,
|
||||
onDidChangeDataset,
|
||||
};
|
||||
84
superset-frontend/src/core/explore/index.ts
Normal file
84
superset-frontend/src/core/explore/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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 `explore` namespace.
|
||||
*
|
||||
* Wraps Redux explore state and normalizes it into the stable `ChartContext`
|
||||
* contract. Extensions must not depend on the Redux slice structure directly.
|
||||
*/
|
||||
|
||||
import type { explore as exploreApi } from '@apache-superset/core';
|
||||
import { HYDRATE_EXPLORE } from 'src/explore/actions/hydrateExplore';
|
||||
import {
|
||||
SET_FORM_DATA,
|
||||
UPDATE_CHART_TITLE,
|
||||
} from 'src/explore/actions/exploreActions';
|
||||
import { SET_DATASOURCE } from 'src/explore/actions/datasourcesActions';
|
||||
import { store, RootState } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import { createActionListener } from '../utils';
|
||||
|
||||
type ChartContext = exploreApi.ChartContext;
|
||||
|
||||
function buildChartContext(): ChartContext | undefined {
|
||||
const state = store.getState();
|
||||
const exploreState = (state as any).explore;
|
||||
if (!exploreState) return undefined;
|
||||
|
||||
const { slice, datasource, controls } = exploreState;
|
||||
const vizType: string =
|
||||
(controls?.viz_type?.value as string) ??
|
||||
exploreState.form_data?.viz_type ??
|
||||
'';
|
||||
|
||||
return {
|
||||
chartId: slice?.slice_id ?? null,
|
||||
chartName: exploreState.sliceName ?? slice?.slice_name ?? null,
|
||||
vizType,
|
||||
datasourceId: datasource?.id ?? null,
|
||||
datasourceName:
|
||||
datasource?.table_name ?? datasource?.datasource_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const exploreChangePredicate: AnyListenerPredicate<RootState> = action =>
|
||||
action.type === HYDRATE_EXPLORE ||
|
||||
action.type === SET_FORM_DATA ||
|
||||
action.type === UPDATE_CHART_TITLE ||
|
||||
action.type === SET_DATASOURCE;
|
||||
|
||||
const getCurrentChart: typeof exploreApi.getCurrentChart = () =>
|
||||
buildChartContext();
|
||||
|
||||
const onDidChangeChart: typeof exploreApi.onDidChangeChart = (
|
||||
listener: (ctx: ChartContext) => void,
|
||||
thisArgs?: any,
|
||||
) =>
|
||||
createActionListener<ChartContext>(
|
||||
exploreChangePredicate,
|
||||
listener,
|
||||
() => buildChartContext() ?? null,
|
||||
thisArgs,
|
||||
);
|
||||
|
||||
export const explore: typeof exploreApi = {
|
||||
getCurrentChart,
|
||||
onDidChangeChart,
|
||||
};
|
||||
@@ -28,10 +28,14 @@ export const core: typeof coreType = {
|
||||
|
||||
export * from './authentication';
|
||||
export * from './commands';
|
||||
export * from './dashboard';
|
||||
export * from './dataset';
|
||||
export * from './editors';
|
||||
export * from './explore';
|
||||
export * from './extensions';
|
||||
export * from './menus';
|
||||
export * from './models';
|
||||
export * from './navigation';
|
||||
export * from './sqlLab';
|
||||
export * from './utils';
|
||||
export * from './views';
|
||||
|
||||
69
superset-frontend/src/core/navigation/index.ts
Normal file
69
superset-frontend/src/core/navigation/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
type PageType = navigationApi.PageType;
|
||||
|
||||
const listeners = new Set<(pageType: PageType) => void>();
|
||||
|
||||
function derivePageType(pathname: string): PageType {
|
||||
if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
|
||||
if (pathname.startsWith('/explore/')) return 'explore';
|
||||
if (pathname.startsWith('/superset/explore/')) return 'explore';
|
||||
if (pathname.startsWith('/chart/add')) return 'explore';
|
||||
if (pathname.startsWith('/sqllab/')) return 'sqllab';
|
||||
if (pathname.startsWith('/dataset/')) return 'dataset';
|
||||
if (pathname.startsWith('/superset/welcome/')) return 'home';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
let currentPageType: PageType = derivePageType(window.location.pathname);
|
||||
|
||||
/** Called by ExtensionsStartup whenever the React Router location changes. */
|
||||
export const notifyPageChange = (pathname: string): void => {
|
||||
const next = derivePageType(pathname);
|
||||
if (next === currentPageType) return;
|
||||
currentPageType = next;
|
||||
listeners.forEach(fn => fn(next));
|
||||
};
|
||||
|
||||
const getPageType: typeof navigationApi.getPageType = () => currentPageType;
|
||||
|
||||
const onDidChangePage: typeof navigationApi.onDidChangePage = (
|
||||
listener: (pageType: PageType) => void,
|
||||
thisArgs?: any,
|
||||
): Disposable => {
|
||||
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
listeners.add(bound);
|
||||
return new Disposable(() => listeners.delete(bound));
|
||||
};
|
||||
|
||||
export const navigation: typeof navigationApi = {
|
||||
getPageType,
|
||||
onDidChangePage,
|
||||
};
|
||||
@@ -17,7 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { views, resolveView } from './index';
|
||||
import {
|
||||
views,
|
||||
resolveView,
|
||||
getViewProvider,
|
||||
getRegisteredViewIds,
|
||||
} from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
@@ -110,3 +115,59 @@ test('dispose removes the view registration', () => {
|
||||
|
||||
expect(views.getViews('sqllab.panels')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getViewProvider returns the registered provider for a matching location', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'test.provider', name: 'Test Provider' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getViewProvider('superset.chatbot', 'test.provider')).toBe(provider);
|
||||
});
|
||||
|
||||
test('getViewProvider returns undefined when the location does not match', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'test.provider', name: 'Test Provider' },
|
||||
'sqllab.panels',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
// Registered, but at a different location.
|
||||
expect(getViewProvider('superset.chatbot', 'test.provider')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getViewProvider returns undefined for an unknown id', () => {
|
||||
expect(getViewProvider('superset.chatbot', 'nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getRegisteredViewIds returns ids in registration order', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getRegisteredViewIds('superset.chatbot')).toEqual([
|
||||
'first.chatbot',
|
||||
'second.chatbot',
|
||||
]);
|
||||
});
|
||||
|
||||
test('getRegisteredViewIds returns an empty array for an unused location', () => {
|
||||
expect(getRegisteredViewIds('superset.chatbot')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,27 @@ const viewRegistry: Map<
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
/** Listeners notified whenever a view is registered or unregistered at a location. */
|
||||
const locationListeners: Map<string, Set<() => void>> = new Map();
|
||||
|
||||
const notifyListeners = (location: string) => {
|
||||
locationListeners.get(location)?.forEach(fn => fn());
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to registration changes at a specific location.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export const subscribeToLocation = (
|
||||
location: string,
|
||||
listener: () => void,
|
||||
): (() => void) => {
|
||||
const listeners = locationListeners.get(location) ?? new Set();
|
||||
listeners.add(listener);
|
||||
locationListeners.set(location, listeners);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
@@ -52,9 +73,12 @@ const registerView: typeof viewsApi.registerView = (
|
||||
ids.add(id);
|
||||
locationIndex.set(location, ids);
|
||||
|
||||
notifyListeners(location);
|
||||
|
||||
return new Disposable(() => {
|
||||
viewRegistry.delete(id);
|
||||
locationIndex.get(location)?.delete(id);
|
||||
notifyListeners(location);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -77,6 +101,28 @@ const getViews: typeof viewsApi.getViews = (
|
||||
.filter((c): c is View => !!c);
|
||||
};
|
||||
|
||||
/**
|
||||
* Host-internal: returns the provider for a registered view id at a location.
|
||||
* Not part of the public `@apache-superset/core` API — `getViews` stays
|
||||
* descriptor-only so extensions cannot render each other's views directly.
|
||||
*/
|
||||
export const getViewProvider = (
|
||||
location: string,
|
||||
id: string,
|
||||
): (() => ReactElement) | undefined => {
|
||||
const entry = viewRegistry.get(id);
|
||||
if (entry?.location !== location) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.provider;
|
||||
};
|
||||
|
||||
/** Host-internal: view ids at a location in registration order. */
|
||||
export const getRegisteredViewIds = (location: string): string[] => {
|
||||
const ids = locationIndex.get(location);
|
||||
return ids ? Array.from(ids) : [];
|
||||
};
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
getViews,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { parse as parseContentDisposition } from 'content-disposition';
|
||||
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
|
||||
import { MenuKeys } from 'src/dashboard/types';
|
||||
import downloadAsPdf from 'src/utils/downloadAsPdf';
|
||||
@@ -122,7 +122,7 @@ export const useDownloadMenuItems = (
|
||||
|
||||
if (disposition) {
|
||||
try {
|
||||
const parsed = contentDisposition.parse(disposition);
|
||||
const parsed = parseContentDisposition(disposition);
|
||||
if (parsed?.parameters?.filename) {
|
||||
fileName = parsed.parameters.filename;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useSelector } from 'react-redux';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { last } from 'lodash';
|
||||
import rison from 'rison';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { parse as parseContentDisposition } from 'content-disposition';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient, SupersetApiError } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
@@ -105,7 +105,7 @@ export const useDownloadScreenshot = (
|
||||
|
||||
if (disposition) {
|
||||
try {
|
||||
const parsed = contentDisposition.parse(disposition);
|
||||
const parsed = parseContentDisposition(disposition);
|
||||
if (parsed?.parameters?.filename) {
|
||||
fileName = parsed.parameters.filename;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ const StyledSyntaxContainer = styled.div`
|
||||
|
||||
const StyledThemedSyntaxHighlighter = styled(CodeSyntaxHighlighter)`
|
||||
flex: 1;
|
||||
height: ${({ theme }) => theme.sizeUnit * 26}px;
|
||||
margin-top: 0;
|
||||
`;
|
||||
|
||||
const StyledFooter = styled.div`
|
||||
@@ -163,7 +165,12 @@ const ViewQuery: FC<ViewQueryProps> = props => {
|
||||
) : (
|
||||
<StyledThemedSyntaxHighlighter
|
||||
language={language}
|
||||
customStyle={{ flex: 1, marginBottom: theme.sizeUnit * 3 }}
|
||||
customStyle={{
|
||||
flex: 1,
|
||||
marginBottom: theme.sizeUnit * 3,
|
||||
fontSize: theme.fontSize * 0.75,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{currentSQL}
|
||||
</StyledThemedSyntaxHighlighter>
|
||||
|
||||
@@ -17,20 +17,31 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import { css } from '@apache-superset/core/theme';
|
||||
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { Select } from '@superset-ui/core/components';
|
||||
import { Switch } from '@superset-ui/core/components/Switch';
|
||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||
import { ListView } from 'src/components';
|
||||
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getRegisteredViewIds } from 'src/core/views';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
type Extension = {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type ExtensionSettings = {
|
||||
active_chatbot_id: string | null;
|
||||
enabled: Record<string, boolean>;
|
||||
};
|
||||
|
||||
interface ExtensionsListProps {
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
@@ -50,6 +61,45 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
|
||||
addDangerToast,
|
||||
);
|
||||
|
||||
const [settings, setSettings] = useState<ExtensionSettings>({
|
||||
active_chatbot_id: null,
|
||||
enabled: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
|
||||
.then(({ json }) => setSettings(json.result))
|
||||
.catch(() => addDangerToast(t('Failed to load extension settings.')));
|
||||
}, [addDangerToast]);
|
||||
|
||||
const saveSettings = useCallback(
|
||||
(patch: Partial<ExtensionSettings>) => {
|
||||
const next = { ...settings, ...patch };
|
||||
SupersetClient.put({
|
||||
endpoint: '/api/v1/extensions/settings',
|
||||
jsonPayload: next,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setSettings(json.result);
|
||||
addSuccessToast(t('Settings saved.'));
|
||||
})
|
||||
.catch(() => addDangerToast(t('Failed to save extension settings.')));
|
||||
},
|
||||
[settings, addDangerToast, addSuccessToast],
|
||||
);
|
||||
|
||||
const toggleEnabled = useCallback(
|
||||
(extensionId: string, enabled: boolean) => {
|
||||
saveSettings({ enabled: { ...settings.enabled, [extensionId]: enabled } });
|
||||
},
|
||||
[settings, saveSettings],
|
||||
);
|
||||
|
||||
const chatbotExtensions = useMemo(() => {
|
||||
const chatbotIds = new Set(getRegisteredViewIds(CHATBOT_LOCATION));
|
||||
return resourceCollection.filter(ext => chatbotIds.has(ext.id));
|
||||
}, [resourceCollection]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -58,15 +108,34 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
|
||||
size: 'lg',
|
||||
id: 'name',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { name },
|
||||
},
|
||||
row: { original: { name } },
|
||||
}: any) => name,
|
||||
},
|
||||
{
|
||||
Header: t('Enabled'),
|
||||
accessor: 'enabled',
|
||||
size: 'sm',
|
||||
id: 'enabled',
|
||||
Cell: ({
|
||||
row: { original: { id, enabled } },
|
||||
}: any) => (
|
||||
<Switch
|
||||
data-test="toggle-enabled"
|
||||
checked={settings.enabled[id] ?? enabled}
|
||||
onClick={(checked: boolean) => toggleEnabled(id, checked)}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[loading], // We need to monitor loading to avoid stale state in actions
|
||||
[loading, settings, toggleEnabled],
|
||||
);
|
||||
|
||||
const chatbotOptions = chatbotExtensions.map(ext => ({
|
||||
label: ext.name,
|
||||
value: ext.id,
|
||||
}));
|
||||
|
||||
const menuData: SubMenuProps = {
|
||||
activeChild: 'Extensions',
|
||||
name: t('Extensions'),
|
||||
@@ -76,6 +145,23 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
|
||||
return (
|
||||
<>
|
||||
<SubMenu {...menuData} />
|
||||
{chatbotOptions.length > 1 && (
|
||||
<div style={{ padding: '16px 24px' }}>
|
||||
<label htmlFor="chatbot-select" style={{ marginRight: 8 }}>
|
||||
{t('Default chatbot')}
|
||||
</label>
|
||||
<Select
|
||||
allowClear
|
||||
options={chatbotOptions}
|
||||
value={settings.active_chatbot_id ?? undefined}
|
||||
onChange={value =>
|
||||
saveSettings({ active_chatbot_id: (value as string) ?? null })
|
||||
}
|
||||
placeholder={t('First registered (automatic)')}
|
||||
css={css`width: 280px;`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ListView<Extension>
|
||||
columns={columns}
|
||||
count={resourceCount}
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import type { common as core } from '@apache-superset/core';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { store } from 'src/views/store';
|
||||
|
||||
type Extension = core.Extension;
|
||||
|
||||
@@ -36,6 +39,9 @@ class ExtensionsLoader {
|
||||
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
/** Disposables returned by contribution registrations, keyed by extension id. */
|
||||
private extensionDisposables: Map<string, (() => void)[]> = new Map();
|
||||
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
private constructor() {
|
||||
// Private constructor for singleton pattern
|
||||
@@ -88,7 +94,8 @@ class ExtensionsLoader {
|
||||
public async initializeExtension(extension: Extension) {
|
||||
try {
|
||||
if (extension.remoteEntry) {
|
||||
await this.loadModule(extension);
|
||||
const disposables = await this.loadModule(extension);
|
||||
this.extensionDisposables.set(extension.id, disposables);
|
||||
}
|
||||
this.extensionIndex.set(extension.id, extension);
|
||||
} catch (error) {
|
||||
@@ -96,15 +103,31 @@ class ExtensionsLoader {
|
||||
`Failed to initialize extension ${extension.name}\n`,
|
||||
error,
|
||||
);
|
||||
store.dispatch(
|
||||
addDangerToast(t('Extension "%s" failed to load.', extension.name)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates an extension by disposing all of its registered contributions
|
||||
* and removing it from the index.
|
||||
*/
|
||||
public deactivateExtension(id: string): void {
|
||||
const disposables = this.extensionDisposables.get(id);
|
||||
if (disposables) {
|
||||
disposables.forEach(dispose => dispose());
|
||||
this.extensionDisposables.delete(id);
|
||||
}
|
||||
this.extensionIndex.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a single extension module via webpack module federation.
|
||||
* The module's top-level side effects fire contribution registrations.
|
||||
* @param extension The extension to load.
|
||||
*/
|
||||
private async loadModule(extension: Extension): Promise<void> {
|
||||
private async loadModule(extension: Extension): Promise<(() => void)[]> {
|
||||
const { remoteEntry, id } = extension;
|
||||
|
||||
// Load the remote entry script
|
||||
@@ -149,8 +172,33 @@ class ExtensionsLoader {
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
|
||||
const factory = await container.get('./index');
|
||||
// Execute the module factory - side effects fire registrations
|
||||
factory();
|
||||
|
||||
// Intercept contribution registrations during module activation so we can
|
||||
// collect the Disposables and drive cleanup on deactivation.
|
||||
const collected: (() => void)[] = [];
|
||||
const originalSuperset = window.superset;
|
||||
window.superset = {
|
||||
...originalSuperset,
|
||||
views: {
|
||||
...originalSuperset.views,
|
||||
registerView: (
|
||||
...args: Parameters<typeof originalSuperset.views.registerView>
|
||||
) => {
|
||||
const disposable = originalSuperset.views.registerView(...args);
|
||||
collected.push(() => disposable.dispose());
|
||||
return disposable;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// Execute the module factory — side effects fire contribution registrations
|
||||
factory();
|
||||
} finally {
|
||||
window.superset = originalSuperset;
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,20 +16,27 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
authentication,
|
||||
core,
|
||||
commands,
|
||||
dashboard,
|
||||
dataset,
|
||||
editors,
|
||||
explore,
|
||||
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';
|
||||
@@ -40,9 +47,13 @@ declare global {
|
||||
authentication: typeof authentication;
|
||||
core: typeof core;
|
||||
commands: typeof commands;
|
||||
dashboard: typeof dashboard;
|
||||
dataset: typeof dataset;
|
||||
editors: typeof editors;
|
||||
explore: typeof explore;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
navigation: typeof navigation;
|
||||
sqlLab: typeof sqlLab;
|
||||
views: typeof views;
|
||||
};
|
||||
@@ -53,11 +64,40 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
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 (prevPathname.current !== location.pathname) {
|
||||
prevPathname.current = location.pathname;
|
||||
notifyPageChange(location.pathname);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// Isolate unhandled rejections from extension code for the lifetime of the
|
||||
// app — registered once, never removed.
|
||||
useEffect(() => {
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
logging.error(
|
||||
'[extensions] Unhandled rejection from extension:',
|
||||
event.reason,
|
||||
);
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'unhandledrejection',
|
||||
handleUnhandledRejection,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
|
||||
@@ -73,21 +113,25 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
authentication,
|
||||
core,
|
||||
commands,
|
||||
dashboard,
|
||||
dataset,
|
||||
editors,
|
||||
explore,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
};
|
||||
|
||||
const setup = async () => {
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
await ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
setInitialized(true);
|
||||
};
|
||||
// Render the host immediately; extension bundles load in the background.
|
||||
// ChatbotMount re-resolves reactively once the chatbot extension registers
|
||||
// (via subscribeToLocation), so the bubble appears without blocking the UI.
|
||||
setInitialized(true);
|
||||
|
||||
setup();
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
}, [initialized, userId]);
|
||||
|
||||
if (!initialized) {
|
||||
|
||||
@@ -103,10 +103,12 @@ test('does not allow user to create a report without a name', () => {
|
||||
});
|
||||
|
||||
test('creates a new email report via modal Add button', async () => {
|
||||
// The modal now calls POST /api/v1/report/subscribe; creation_method, owners, and
|
||||
// recipients are derived server-side — the client payload intentionally omits them.
|
||||
fetchMock.post(
|
||||
REPORT_ENDPOINT,
|
||||
'glob:*/api/v1/report/subscribe',
|
||||
{ id: 1, result: {} },
|
||||
{ name: 'post-report' },
|
||||
{ name: 'post-subscribe' },
|
||||
);
|
||||
|
||||
render(<ReportModal {...defaultProps} />, { useRedux: true });
|
||||
@@ -114,22 +116,22 @@ test('creates a new email report via modal Add button', async () => {
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
await waitFor(() => userEvent.click(addButton));
|
||||
|
||||
// Verify exactly one POST from the modal submit path
|
||||
// Verify exactly one POST to the subscribe endpoint
|
||||
await waitFor(() => {
|
||||
const postCalls = fetchMock.callHistory.calls('post-report');
|
||||
const postCalls = fetchMock.callHistory.calls('post-subscribe');
|
||||
expect(postCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
const postCalls = fetchMock.callHistory.calls('post-report');
|
||||
const postCalls = fetchMock.callHistory.calls('post-subscribe');
|
||||
const body = JSON.parse(postCalls[0].options.body as string);
|
||||
expect(body.name).toBe('Weekly Report');
|
||||
expect(body.type).toBe('Report');
|
||||
expect(body.creation_method).toBe('dashboards');
|
||||
expect(body.crontab).toBeDefined();
|
||||
expect(body.recipients).toBeDefined();
|
||||
expect(body.recipients[0].type).toBe('Email');
|
||||
// creation_method, owners, and recipients are set server-side; not in the client payload
|
||||
expect(body.creation_method).toBeUndefined();
|
||||
expect(body.recipients).toBeUndefined();
|
||||
|
||||
fetchMock.removeRoute('post-report');
|
||||
fetchMock.removeRoute('post-subscribe');
|
||||
});
|
||||
|
||||
test('text-based chart hides screenshot width and shows message content', () => {
|
||||
|
||||
@@ -174,6 +174,28 @@ export const addReport =
|
||||
throw err;
|
||||
});
|
||||
|
||||
export const SUBSCRIBE_REPORT = 'SUBSCRIBE_REPORT' as const;
|
||||
|
||||
export interface SubscribeReportAction {
|
||||
type: typeof SUBSCRIBE_REPORT;
|
||||
json: ReportApiJsonResponse;
|
||||
}
|
||||
|
||||
export const subscribeReport =
|
||||
(report: Partial<ReportObject>) => (dispatch: Dispatch<AnyAction>) =>
|
||||
SupersetClient.post({
|
||||
endpoint: `/api/v1/report/subscribe`,
|
||||
jsonPayload: report,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
dispatch({ type: SUBSCRIBE_REPORT, json } as SubscribeReportAction);
|
||||
dispatch(addSuccessToast(t('The report has been created')));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(addDangerToast(t('Failed to create report')));
|
||||
throw err;
|
||||
});
|
||||
|
||||
export const EDIT_REPORT = 'EDIT_REPORT' as const;
|
||||
|
||||
export interface EditReportAction {
|
||||
@@ -255,5 +277,6 @@ export function deleteActiveReport(report: DeletableReport) {
|
||||
export type ReportAction =
|
||||
| SetReportAction
|
||||
| AddReportAction
|
||||
| SubscribeReportAction
|
||||
| EditReportAction
|
||||
| DeleteReportAction;
|
||||
|
||||
@@ -31,8 +31,8 @@ import { Alert } from '@apache-superset/core/components';
|
||||
import { SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
addReport,
|
||||
editReport,
|
||||
subscribeReport,
|
||||
} from 'src/features/reports/ReportModal/actions';
|
||||
import {
|
||||
Input,
|
||||
@@ -179,26 +179,13 @@ function ReportModal({
|
||||
}, [isEditMode, report]);
|
||||
|
||||
const onSave = async () => {
|
||||
// Create new Report
|
||||
const newReportValues: Partial<ReportObject> = {
|
||||
const commonFields: Partial<ReportObject> = {
|
||||
type: 'Report',
|
||||
active: true,
|
||||
force_screenshot: false,
|
||||
custom_width: currentReport.custom_width,
|
||||
creation_method: creationMethod,
|
||||
dashboard: dashboardId,
|
||||
chart: chart?.id,
|
||||
owners: [userId],
|
||||
recipients: [
|
||||
{
|
||||
recipient_config_json: {
|
||||
target: userEmail,
|
||||
ccTarget: ccEmail,
|
||||
bccTarget: bccEmail,
|
||||
},
|
||||
type: 'Email',
|
||||
},
|
||||
],
|
||||
name: currentReport.name,
|
||||
description: currentReport.description,
|
||||
crontab: currentReport.crontab,
|
||||
@@ -209,12 +196,27 @@ function ReportModal({
|
||||
setCurrentReport({ isSubmitting: true, error: undefined });
|
||||
try {
|
||||
if (isEditMode && currentReport.id) {
|
||||
// Edit path: include all fields, PUT endpoint accepts recipients/owners directly
|
||||
await dispatch(
|
||||
editReport(currentReport.id, newReportValues as ReportObject),
|
||||
editReport(currentReport.id, {
|
||||
...commonFields,
|
||||
creation_method: creationMethod,
|
||||
owners: [userId],
|
||||
recipients: [
|
||||
{
|
||||
recipient_config_json: {
|
||||
target: userEmail,
|
||||
ccTarget: ccEmail,
|
||||
bccTarget: bccEmail,
|
||||
},
|
||||
type: 'Email',
|
||||
},
|
||||
],
|
||||
} as ReportObject),
|
||||
);
|
||||
} else {
|
||||
// Create new report (either not in edit mode, or edit mode without valid ID)
|
||||
await dispatch(addReport(newReportValues as ReportObject));
|
||||
// Subscribe path: creation_method, owners, and recipients are set server-side.
|
||||
await dispatch(subscribeReport(commonFields as ReportObject));
|
||||
}
|
||||
onHide();
|
||||
} catch (e) {
|
||||
|
||||
@@ -21,11 +21,13 @@ import { omit } from 'lodash';
|
||||
import {
|
||||
SET_REPORT,
|
||||
ADD_REPORT,
|
||||
SUBSCRIBE_REPORT,
|
||||
EDIT_REPORT,
|
||||
DELETE_REPORT,
|
||||
ReportAction,
|
||||
SetReportAction,
|
||||
AddReportAction,
|
||||
SubscribeReportAction,
|
||||
EditReportAction,
|
||||
DeleteReportAction,
|
||||
} from './actions';
|
||||
@@ -105,6 +107,25 @@ export default function reportsReducer(
|
||||
};
|
||||
},
|
||||
|
||||
[SUBSCRIBE_REPORT]() {
|
||||
const { result, id } = (action as SubscribeReportAction).json;
|
||||
const report: ReportObject = { ...result, id } as ReportObject;
|
||||
const creationMethod = report.creation_method as ReportCreationMethod;
|
||||
const key = report.dashboard ?? report.chart;
|
||||
|
||||
if (key === undefined) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
[creationMethod]: {
|
||||
...state[creationMethod],
|
||||
[key]: report,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
[EDIT_REPORT]() {
|
||||
const actionTyped = action as EditReportAction;
|
||||
const report: ReportObject = {
|
||||
|
||||
@@ -117,6 +117,7 @@ type LaunchQueue = {
|
||||
|
||||
const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
|
||||
const MAX_CONSUMER_POLL_ATTEMPTS = 50;
|
||||
const consumerPromises: Promise<void>[] = [];
|
||||
|
||||
// Defer the consumer call to a macrotask so it doesn't fire synchronously inside
|
||||
// the component's useEffect — calling it inline deadlocks Jest because the
|
||||
@@ -131,7 +132,11 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
|
||||
if (fileHandle) {
|
||||
const id = setTimeout(() => {
|
||||
pendingTimerIds.delete(id);
|
||||
consumer({ files: [fileHandle] });
|
||||
consumerPromises.push(
|
||||
Promise.resolve(consumer({ files: [fileHandle] })).then(
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
}, 0);
|
||||
pendingTimerIds.add(id);
|
||||
}
|
||||
@@ -165,9 +170,19 @@ beforeEach(() => {
|
||||
.launchQueue;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
pendingTimerIds.forEach(id => clearTimeout(id));
|
||||
pendingTimerIds.clear();
|
||||
if (consumerPromises.length > 0) {
|
||||
const results = await Promise.allSettled(consumerPromises);
|
||||
results.forEach(r => {
|
||||
if (r.status === 'rejected') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('LaunchQueue consumer rejected:', r.reason);
|
||||
}
|
||||
});
|
||||
consumerPromises.length = 0;
|
||||
}
|
||||
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
|
||||
.launchQueue;
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user