mirror of
https://github.com/apache/superset.git
synced 2026-06-30 11:55:31 +00:00
Compare commits
52 Commits
chart-samp
...
chore/sqla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5564f8c50 | ||
|
|
90fe1f5b7c | ||
|
|
a529945d3b | ||
|
|
0c12114ea9 | ||
|
|
6eb51105d0 | ||
|
|
b2a2698898 | ||
|
|
efed2b09aa | ||
|
|
c3a11e3170 | ||
|
|
98d61c5cf8 | ||
|
|
7d5e01b6cf | ||
|
|
a70d055669 | ||
|
|
670f25e5f4 | ||
|
|
7fc1113c31 | ||
|
|
ccbd284245 | ||
|
|
d0268442f8 | ||
|
|
25c9f3510a | ||
|
|
b8fd2e9725 | ||
|
|
78dd400ca4 | ||
|
|
7587d0778a | ||
|
|
97cb002f46 | ||
|
|
5ec0931840 | ||
|
|
3eb9185521 | ||
|
|
cd8ac41d16 | ||
|
|
21999bb772 | ||
|
|
0a18779280 | ||
|
|
a147079043 | ||
|
|
ebb32de625 | ||
|
|
1280eaee18 | ||
|
|
15626a047c | ||
|
|
dc64716c61 | ||
|
|
6f12dbf0e1 | ||
|
|
022f66a694 | ||
|
|
ac9bf26751 | ||
|
|
834ccf2613 | ||
|
|
98d0ccd7a7 | ||
|
|
8aacb6f793 | ||
|
|
eaaab61493 | ||
|
|
068a709c14 | ||
|
|
71c8e2f69d | ||
|
|
bfa6cfac85 | ||
|
|
c03cdade39 | ||
|
|
0efcbcdd81 | ||
|
|
11d7f7fb87 | ||
|
|
c87fdfc18f | ||
|
|
667005638a | ||
|
|
f10315f8fc | ||
|
|
a5dbb394e5 | ||
|
|
f49db9e536 | ||
|
|
84e07df735 | ||
|
|
b8f3918bcf | ||
|
|
ee43d8869f | ||
|
|
01a0c66c79 |
2
.github/actions/setup-backend/action.yml
vendored
2
.github/actions/setup-backend/action.yml
vendored
@@ -42,7 +42,7 @@ runs:
|
||||
fi
|
||||
echo "python-version=$RESOLVED_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Set up Python ${{ steps.set-python-version.outputs.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ steps.set-python-version.outputs.python-version }}
|
||||
cache: ${{ inputs.cache }}
|
||||
|
||||
2
.github/workflows/bump-python-package.yml
vendored
2
.github/workflows/bump-python-package.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
|
||||
2
.github/workflows/pre-commit.yml
vendored
2
.github/workflows/pre-commit.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
yarn install --immutable
|
||||
|
||||
- name: Cache pre-commit environments
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
- name: Cache npm
|
||||
if: env.HAS_TAGS
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS
|
||||
key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
- name: Cache npm
|
||||
if: env.HAS_TAGS
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: npm-cache # use this to check for `cache-hit` (`steps.npm-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.npm-cache-dir-path.outputs.dir }}
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: "Checkout release tag: ${{ needs.config.outputs.latest-release }}"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ needs.config.outputs.latest-release }}
|
||||
fetch-depth: 0
|
||||
|
||||
4
.github/workflows/superset-websocket.yml
vendored
4
.github/workflows/superset-websocket.yml
vendored
@@ -28,6 +28,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: './superset-websocket/.nvmrc'
|
||||
- name: Install dependencies
|
||||
working-directory: ./superset-websocket
|
||||
run: npm ci
|
||||
|
||||
@@ -36,6 +36,10 @@ When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audien
|
||||
|
||||
`FAB_API_SWAGGER_UI` now defaults to `False` and is driven by the `SUPERSET_ENABLE_SWAGGER_UI` environment variable. The interactive Swagger UI / OpenAPI documentation endpoints (e.g. `/swagger/v1`) are therefore no longer exposed by default. To enable them, set `SUPERSET_ENABLE_SWAGGER_UI=true` (the bundled Docker development environment sets this) or override `FAB_API_SWAGGER_UI = True` in `superset_config.py`.
|
||||
|
||||
### Build details (git SHA / build number) are admin-only by default
|
||||
|
||||
The git SHA and build number surfaced in the "About" section, the bootstrap payload, and the public `/version` endpoint are now only included for admin users by default; the release version string is still shown to everyone. To expose the build details to all users (the previous behavior), set the `SUPERSET_EXPOSE_BUILD_DETAILS` environment variable (or `EXPOSE_BUILD_DETAILS_TO_USERS = True` in `superset_config.py`).
|
||||
|
||||
### Pivot table First/Last aggregations follow data order
|
||||
|
||||
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
v24.16.0
|
||||
1
docs/.nvmrc
Symbolic link
1
docs/.nvmrc
Symbolic link
@@ -0,0 +1 @@
|
||||
../superset-frontend/.nvmrc
|
||||
@@ -160,7 +160,7 @@ When enabled, Superset rejects webhook configurations that use `http://` URLs.
|
||||
|
||||
#### Retry Behavior
|
||||
|
||||
Superset automatically retries webhook deliveries on `429 Too Many Requests` and `5xx` server errors using exponential backoff.
|
||||
Superset automatically retries webhook deliveries on `429 Too Many Requests` and `5xx` server errors using exponential backoff. Retries are bounded to roughly 120 seconds of cumulative wall-clock time (worst case ~210 seconds, because the bound is checked against the time elapsed before each attempt, so the final request can begin just under the limit and still run its full request timeout), after which the delivery is abandoned.
|
||||
|
||||
### Kubernetes-specific
|
||||
|
||||
|
||||
@@ -223,8 +223,9 @@ compose based installation, edit the `x-superset-image:` line in your `docker-co
|
||||
`docker-compose-non-dev.yml` files, replacing `apachesuperset.docker.scarf.sh/apache/superset` with
|
||||
`apache/superset` to pull the image directly from Docker Hub.
|
||||
|
||||
To disable the Scarf telemetry pixel, set the `SCARF_ANALYTICS` environment variable to `False` in
|
||||
your terminal and/or in your `docker/.env` file.
|
||||
To disable the Scarf telemetry pixel, set the `SCARF_ANALYTICS` environment variable to `false` in
|
||||
your `docker/.env` file. This is read at runtime, so it disables the pixel on the pre-built image
|
||||
without rebuilding the frontend.
|
||||
:::
|
||||
|
||||
## 3. Log in to Superset
|
||||
|
||||
@@ -136,7 +136,17 @@ init:
|
||||
:::note
|
||||
Superset uses [Scarf Gateway](https://about.scarf.sh/scarf-gateway) to collect telemetry data. Knowing the installation counts for different Superset versions informs the project's decisions about patching and long-term support. Scarf purges personally identifiable information (PII) and provides only aggregated statistics.
|
||||
|
||||
To opt-out of this data collection in your Helm-based installation, edit the `repository:` line in your `helm/superset/values.yaml` file, replacing `apachesuperset.docker.scarf.sh/apache/superset` with `apache/superset` to pull the image directly from Docker Hub.
|
||||
There are two independent telemetry channels:
|
||||
|
||||
- **Image pulls** (Scarf Gateway): to opt out, edit the `repository:` line in your `helm/superset/values.yaml` file, replacing `apachesuperset.docker.scarf.sh/apache/superset` with `apache/superset` to pull the image directly from Docker Hub.
|
||||
- **The analytics pixel** rendered in the UI: to opt out, set the `SCARF_ANALYTICS` environment variable to `false` on the Superset containers via `extraEnv` in your `values.yaml`:
|
||||
|
||||
```yaml
|
||||
extraEnv:
|
||||
SCARF_ANALYTICS: "false"
|
||||
```
|
||||
|
||||
This is read at runtime, so it takes effect on the pre-built images without rebuilding the frontend.
|
||||
:::
|
||||
|
||||
### Dependencies
|
||||
|
||||
@@ -321,8 +321,8 @@ This can be used, for example, to convert UTC time to local time.
|
||||
Superset uses [Scarf](https://about.scarf.sh/) by default to collect basic telemetry data upon installing and/or running Superset. This data helps the maintainers of Superset better understand which versions of Superset are being used, in order to prioritize patch/minor releases and security fixes.
|
||||
We use the [Scarf Gateway](https://docs.scarf.sh/gateway/) to sit in front of container registries, the [scarf-js](https://about.scarf.sh/package-sdks) package to track `npm` installations, and a Scarf pixel to gather anonymous analytics on Superset page views.
|
||||
Scarf purges PII and provides aggregated statistics. Superset users can easily opt out of analytics in various ways documented [here](https://docs.scarf.sh/gateway/#do-not-track) and [here](https://docs.scarf.sh/package-analytics/#as-a-user-of-a-package-using-scarf-js-how-can-i-opt-out-of-analytics).
|
||||
Superset maintainers can also opt out of telemetry data collection by setting the `SCARF_ANALYTICS` environment variable to `false` in the Superset container (or anywhere Superset/webpack are run).
|
||||
Additional opt-out instructions for Docker users are available on the [Docker Installation](/admin-docs/installation/docker-compose) page.
|
||||
You can also opt out of the analytics pixel by setting the `SCARF_ANALYTICS` environment variable to `false`. This is read at runtime, so setting it on the Superset container (for example via `extraEnv` in the Helm chart, or `docker/.env` for Docker Compose) disables the pixel on the pre-built images without rebuilding the frontend. Note that this only disables the page-view pixel; the Scarf Gateway (container registry) and `scarf-js` (`npm`) channels are opted out separately, as described above.
|
||||
Additional opt-out instructions are available on the [Docker Compose](/admin-docs/installation/docker-compose) and [Kubernetes](/admin-docs/installation/kubernetes) installation pages.
|
||||
|
||||
## Does Superset have an archive panel or trash bin from which a user can recover deleted assets?
|
||||
|
||||
|
||||
@@ -134,7 +134,8 @@
|
||||
"yaml": "1.10.3",
|
||||
"uuid": "11.1.1",
|
||||
"serialize-javascript": "7.0.5",
|
||||
"d3-color": "3.1.0"
|
||||
"d3-color": "3.1.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
|
||||
}
|
||||
|
||||
@@ -15246,15 +15246,10 @@ write-file-atomic@^3.0.3:
|
||||
signal-exit "^3.0.2"
|
||||
typedarray-to-buffer "^3.1.5"
|
||||
|
||||
ws@^7.3.1:
|
||||
version "7.5.10"
|
||||
resolved "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz"
|
||||
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
|
||||
|
||||
ws@^8.18.0, ws@^8.2.3:
|
||||
version "8.20.1"
|
||||
resolved "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz"
|
||||
integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==
|
||||
ws@^7.3.1, ws@^8.18.0, ws@^8.2.3, ws@^8.21.0:
|
||||
version "8.21.0"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951"
|
||||
integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==
|
||||
|
||||
wsl-utils@^0.1.0:
|
||||
version "0.1.0"
|
||||
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.17.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.18.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -88,6 +88,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| ingress.path | string | `"/"` | |
|
||||
| ingress.pathType | string | `"ImplementationSpecific"` | |
|
||||
| ingress.tls | list | `[]` | |
|
||||
| init.additionalPodSpec | object | `{}` | Custom pod spec to be added to init job |
|
||||
| init.adminUser.email | string | `"admin@superset.com"` | |
|
||||
| init.adminUser.firstname | string | `"Superset"` | |
|
||||
| init.adminUser.lastname | string | `"Admin"` | |
|
||||
@@ -131,6 +132,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| supersetCeleryBeat.affinity | object | `{}` | Affinity to be added to supersetCeleryBeat deployment |
|
||||
| supersetCeleryBeat.command | list | a `celery beat` command | Command |
|
||||
| supersetCeleryBeat.containerSecurityContext | object | `{}` | |
|
||||
| supersetCeleryBeat.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetCeleryBeat deployment |
|
||||
| supersetCeleryBeat.deploymentAnnotations | object | `{}` | Annotations to be added to supersetCeleryBeat deployment |
|
||||
| supersetCeleryBeat.enabled | bool | `false` | This is only required if you intend to use alerts and reports |
|
||||
| supersetCeleryBeat.extraContainers | list | `[]` | Launch additional containers into supersetCeleryBeat pods |
|
||||
@@ -149,6 +151,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| supersetCeleryFlower.affinity | object | `{}` | Affinity to be added to supersetCeleryFlower deployment |
|
||||
| supersetCeleryFlower.command | list | a `celery flower` command | Command |
|
||||
| supersetCeleryFlower.containerSecurityContext | object | `{}` | |
|
||||
| supersetCeleryFlower.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetCeleryFlower deployment |
|
||||
| supersetCeleryFlower.deploymentAnnotations | object | `{}` | Annotations to be added to supersetCeleryFlower deployment |
|
||||
| supersetCeleryFlower.enabled | bool | `false` | Enables a Celery flower deployment (management UI to monitor celery jobs) WARNING: on superset 1.x, this requires a Superset image that has `flower<1.0.0` installed (which is NOT the case of the default images) flower>=1.0.0 requires Celery 5+ which Superset 1.5 does not support |
|
||||
| supersetCeleryFlower.extraContainers | list | `[]` | Launch additional containers into supersetCeleryFlower pods |
|
||||
@@ -204,12 +207,14 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| supersetNode.connections.db_user | string | `"superset"` | |
|
||||
| supersetNode.connections.redis_cache_db | string | `"1"` | |
|
||||
| supersetNode.connections.redis_celery_db | string | `"0"` | |
|
||||
| supersetNode.connections.redis_driver | string | `""` | |
|
||||
| supersetNode.connections.redis_host | string | `"{{ .Release.Name }}-redis-headless"` | Change in case of bringing your own redis and then also set redis.enabled:false |
|
||||
| supersetNode.connections.redis_port | string | `"6379"` | |
|
||||
| supersetNode.connections.redis_ssl.enabled | bool | `false` | |
|
||||
| supersetNode.connections.redis_ssl.ssl_cert_reqs | string | `"CERT_NONE"` | |
|
||||
| supersetNode.connections.redis_user | string | `""` | |
|
||||
| supersetNode.containerSecurityContext | object | `{}` | |
|
||||
| supersetNode.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetNode deployment |
|
||||
| supersetNode.deploymentAnnotations | object | `{}` | Annotations to be added to supersetNode deployment |
|
||||
| supersetNode.deploymentLabels | object | `{}` | Labels to be added to supersetNode deployment |
|
||||
| supersetNode.env | object | `{}` | |
|
||||
@@ -255,6 +260,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| supersetWebsockets.command | list | `[]` | |
|
||||
| supersetWebsockets.config | object | see `values.yaml` | The config.json to pass to the server, see https://github.com/apache/superset/tree/master/superset-websocket Note that the configuration can also read from environment variables (which will have priority), see https://github.com/apache/superset/blob/master/superset-websocket/src/config.ts for a list of supported variables |
|
||||
| supersetWebsockets.containerSecurityContext | object | `{}` | |
|
||||
| supersetWebsockets.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetWebsockets deployment |
|
||||
| supersetWebsockets.deploymentAnnotations | object | `{}` | |
|
||||
| supersetWebsockets.enabled | bool | `false` | This is only required if you intend to use `GLOBAL_ASYNC_QUERIES` in `ws` mode see https://superset.apache.org/docs/contributing/misc#async-chart-queries |
|
||||
| supersetWebsockets.extraContainers | list | `[]` | Launch additional containers into supersetWebsockets pods |
|
||||
@@ -308,6 +314,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| supersetWorker.autoscaling.targetCPUUtilizationPercentage | int | `80` | |
|
||||
| supersetWorker.command | list | a `celery worker` command | Worker startup command |
|
||||
| supersetWorker.containerSecurityContext | object | `{}` | |
|
||||
| supersetWorker.deploymentAdditionalPodSpec | object | `{}` | Custom pod spec to be added to supersetWorker deployment |
|
||||
| supersetWorker.deploymentAnnotations | object | `{}` | Annotations to be added to supersetWorker deployment |
|
||||
| supersetWorker.deploymentLabels | object | `{}` | Labels to be added to supersetWorker deployment |
|
||||
| supersetWorker.extraContainers | list | `[]` | Launch additional containers into supersetWorker pod |
|
||||
|
||||
@@ -71,9 +71,9 @@ def env(key, default=None):
|
||||
|
||||
# Redis Base URL
|
||||
{{- if .Values.supersetNode.connections.redis_password }}
|
||||
REDIS_BASE_URL=f"{env('REDIS_PROTO')}://{env('REDIS_USER', '')}:{env('REDIS_PASSWORD')}@{env('REDIS_HOST')}:{env('REDIS_PORT')}"
|
||||
REDIS_BASE_URL=f"{env('REDIS_DRIVER') or env('REDIS_PROTO')}://{env('REDIS_USER', '')}:{env('REDIS_PASSWORD')}@{env('REDIS_HOST')}:{env('REDIS_PORT')}"
|
||||
{{- else }}
|
||||
REDIS_BASE_URL=f"{env('REDIS_PROTO')}://{env('REDIS_HOST')}:{env('REDIS_PORT')}"
|
||||
REDIS_BASE_URL=f"{env('REDIS_DRIVER') or env('REDIS_PROTO')}://{env('REDIS_HOST')}:{env('REDIS_PORT')}"
|
||||
{{- end }}
|
||||
|
||||
# Redis URL Params
|
||||
|
||||
@@ -68,6 +68,9 @@ spec:
|
||||
{{- toYaml .Values.supersetCeleryBeat.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.supersetCeleryBeat.deploymentAdditionalPodSpec }}
|
||||
{{- tpl (toYaml .Values.supersetCeleryBeat.deploymentAdditionalPodSpec) . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
|
||||
serviceAccountName: {{ template "superset.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -57,6 +57,9 @@ spec:
|
||||
{{- toYaml .Values.supersetCeleryFlower.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.supersetCeleryFlower.deploymentAdditionalPodSpec }}
|
||||
{{- tpl (toYaml .Values.supersetCeleryFlower.deploymentAdditionalPodSpec) . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
|
||||
serviceAccountName: {{ template "superset.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -74,6 +74,9 @@ spec:
|
||||
{{- toYaml .Values.supersetWorker.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.supersetWorker.deploymentAdditionalPodSpec }}
|
||||
{{- tpl (toYaml .Values.supersetWorker.deploymentAdditionalPodSpec) . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
|
||||
serviceAccountName: {{ template "superset.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -60,6 +60,9 @@ spec:
|
||||
{{- toYaml .Values.supersetWebsockets.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.supersetWebsockets.deploymentAdditionalPodSpec }}
|
||||
{{- tpl (toYaml .Values.supersetWebsockets.deploymentAdditionalPodSpec) . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
|
||||
serviceAccountName: {{ template "superset.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -76,6 +76,9 @@ spec:
|
||||
{{- toYaml .Values.supersetNode.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.supersetNode.deploymentAdditionalPodSpec }}
|
||||
{{- tpl (toYaml .Values.supersetNode.deploymentAdditionalPodSpec) . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
|
||||
serviceAccountName: {{ template "superset.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -41,16 +41,21 @@ spec:
|
||||
{{- if .Values.init.podAnnotations }}
|
||||
annotations: {{- toYaml .Values.init.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.extraLabels .Values.init.podLabels }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
job: {{ template "superset.fullname" . }}-init-db
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.init.podLabels }}
|
||||
{{- toYaml .Values.init.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.init.additionalPodSpec }}
|
||||
{{- tpl (toYaml .Values.init.additionalPodSpec) . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- if or (.Values.serviceAccount.create) (.Values.serviceAccountName) }}
|
||||
serviceAccountName: {{ template "superset.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -39,6 +39,7 @@ stringData:
|
||||
{{- end }}
|
||||
REDIS_PORT: {{ .Values.supersetNode.connections.redis_port | quote }}
|
||||
REDIS_PROTO: {{ if .Values.supersetNode.connections.redis_ssl.enabled }}"rediss"{{ else }}"redis"{{ end }}
|
||||
REDIS_DRIVER: {{ .Values.supersetNode.connections.redis_driver | quote }}
|
||||
REDIS_DB: {{ .Values.supersetNode.connections.redis_cache_db | quote }}
|
||||
REDIS_CELERY_DB: {{ .Values.supersetNode.connections.redis_celery_db | quote }}
|
||||
{{- if .Values.supersetNode.connections.redis_ssl.enabled }}
|
||||
|
||||
@@ -283,6 +283,7 @@ supersetNode:
|
||||
redis_ssl:
|
||||
enabled: false
|
||||
ssl_cert_reqs: CERT_NONE
|
||||
redis_driver: ""
|
||||
# You need to change below configuration incase bringing own PostgresSQL instance and also set postgresql.enabled:false
|
||||
# -- Database type for Superset metadata (Supported types: "postgresql", "mysql")
|
||||
db_type: "postgresql"
|
||||
@@ -334,6 +335,8 @@ supersetNode:
|
||||
deploymentAnnotations: {}
|
||||
# -- Labels to be added to supersetNode deployment
|
||||
deploymentLabels: {}
|
||||
# -- Custom pod spec to be added to supersetNode deployment
|
||||
deploymentAdditionalPodSpec: {}
|
||||
# -- Affinity to be added to supersetNode deployment
|
||||
affinity: {}
|
||||
# -- TopologySpreadConstrains to be added to supersetNode deployments
|
||||
@@ -459,6 +462,8 @@ supersetWorker:
|
||||
deploymentAnnotations: {}
|
||||
# -- Labels to be added to supersetWorker deployment
|
||||
deploymentLabels: {}
|
||||
# -- Custom pod spec to be added to supersetWorker deployment
|
||||
deploymentAdditionalPodSpec: {}
|
||||
# -- Affinity to be added to supersetWorker deployment
|
||||
affinity: {}
|
||||
# -- TopologySpreadConstrains to be added to supersetWorker deployments
|
||||
@@ -565,6 +570,8 @@ supersetCeleryBeat:
|
||||
extraContainers: []
|
||||
# -- Annotations to be added to supersetCeleryBeat deployment
|
||||
deploymentAnnotations: {}
|
||||
# -- Custom pod spec to be added to supersetCeleryBeat deployment
|
||||
deploymentAdditionalPodSpec: {}
|
||||
# -- Affinity to be added to supersetCeleryBeat deployment
|
||||
affinity: {}
|
||||
# -- TopologySpreadConstrains to be added to supersetCeleryBeat deployments
|
||||
@@ -680,6 +687,8 @@ supersetCeleryFlower:
|
||||
extraContainers: []
|
||||
# -- Annotations to be added to supersetCeleryFlower deployment
|
||||
deploymentAnnotations: {}
|
||||
# -- Custom pod spec to be added to supersetCeleryFlower deployment
|
||||
deploymentAdditionalPodSpec: {}
|
||||
# -- Affinity to be added to supersetCeleryFlower deployment
|
||||
affinity: {}
|
||||
# -- TopologySpreadConstrains to be added to supersetCeleryFlower deployments
|
||||
@@ -757,6 +766,8 @@ supersetWebsockets:
|
||||
# -- Launch additional containers into supersetWebsockets pods
|
||||
extraContainers: []
|
||||
deploymentAnnotations: {}
|
||||
# -- Custom pod spec to be added to supersetWebsockets deployment
|
||||
deploymentAdditionalPodSpec: {}
|
||||
# -- Affinity to be added to supersetWebsockets deployment
|
||||
affinity: {}
|
||||
# -- TopologySpreadConstrains to be added to supersetWebsockets deployments
|
||||
@@ -819,6 +830,8 @@ init:
|
||||
jobAnnotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-delete-policy": "before-hook-creation"
|
||||
# -- Custom pod spec to be added to init job
|
||||
additionalPodSpec: {}
|
||||
loadExamples: false
|
||||
createAdmin: true
|
||||
adminUser:
|
||||
|
||||
@@ -107,8 +107,8 @@ dependencies = [
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.1",
|
||||
"slack_sdk>=3.19.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlalchemy>=1.4.43, <2", # 1.4.43 adds the python-oracledb (oracle+oracledb) dialect
|
||||
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.8.0, <31",
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.10.0, <1.0",
|
||||
@@ -147,7 +147,7 @@ denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.10, <2"]
|
||||
druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb>=1.5.2,<2", "duckdb-engine>=0.17.0"]
|
||||
duckdb = ["duckdb>=1.5.4,<2", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
solr = ["sqlalchemy-solr >= 0.2.4.3"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
|
||||
@@ -206,7 +206,7 @@ spark = [
|
||||
"thrift>=0.23.0, <1",
|
||||
]
|
||||
tdengine = [
|
||||
"taospy>=2.7.21",
|
||||
"taospy>=2.8.9",
|
||||
"taos-ws-py>=0.6.9"
|
||||
]
|
||||
teradata = ["teradatasql>=16.20.0.23"]
|
||||
|
||||
@@ -43,5 +43,5 @@ filterwarnings =
|
||||
# error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:The Engine.execute\(\) method is considered legacy:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The legacy calling style of select\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:The "whens" argument to case:sqlalchemy.exc.RemovedIn20Warning
|
||||
error:The "whens" argument to case:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:"User" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
|
||||
@@ -405,7 +405,7 @@ sqlalchemy==1.4.54
|
||||
# marshmallow-sqlalchemy
|
||||
# shillelagh
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-utils==0.42.0
|
||||
sqlalchemy-utils==0.42.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
|
||||
@@ -220,7 +220,7 @@ docstring-parser==0.17.0
|
||||
# via cyclopts
|
||||
docutils==0.22.2
|
||||
# via rich-rst
|
||||
duckdb==1.5.3
|
||||
duckdb==1.5.4
|
||||
# via
|
||||
# apache-superset
|
||||
# duckdb-engine
|
||||
@@ -976,7 +976,7 @@ sqlalchemy==1.4.54
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-bigquery==1.17.0
|
||||
# via apache-superset
|
||||
sqlalchemy-utils==0.42.0
|
||||
sqlalchemy-utils==0.42.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
v24.16.0
|
||||
1
superset-embedded-sdk/.nvmrc
Symbolic link
1
superset-embedded-sdk/.nvmrc
Symbolic link
@@ -0,0 +1 @@
|
||||
../superset-frontend/.nvmrc
|
||||
@@ -32,6 +32,7 @@ and therefore are not easily unit-testable. We have instead opted to test the sd
|
||||
This way, the tests can assert that the sdk actually mounts the iframe and communicates with it correctly.
|
||||
|
||||
At time of writing, these tests are not written yet, because we haven't yet put together the demo app that they will leverage.
|
||||
|
||||
### Things to e2e test once we have a demo app:
|
||||
|
||||
**happy path:**
|
||||
|
||||
@@ -41,12 +41,12 @@ npm install --save @superset-ui/embedded-sdk
|
||||
```
|
||||
|
||||
```js
|
||||
import { embedDashboard } from '@superset-ui/embedded-sdk';
|
||||
import { embedDashboard } from "@superset-ui/embedded-sdk";
|
||||
|
||||
embedDashboard({
|
||||
id: 'abc123', // given by the Superset embedding UI
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'), // any html element that can contain an iframe
|
||||
id: "abc123", // given by the Superset embedding UI
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"), // any html element that can contain an iframe
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
dashboardUiConfig: {
|
||||
// dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded (optional), urlParams (optional)
|
||||
@@ -55,21 +55,21 @@ embedDashboard({
|
||||
expanded: true,
|
||||
},
|
||||
urlParams: {
|
||||
foo: 'value1',
|
||||
bar: 'value2',
|
||||
foo: "value1",
|
||||
bar: "value2",
|
||||
// themeMode: 'dark', // set the initial theme: 'dark' | 'system' | 'default' (default: 'default')
|
||||
// ...
|
||||
},
|
||||
},
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: [
|
||||
'allow-top-navigation',
|
||||
'allow-popups-to-escape-sandbox',
|
||||
"allow-top-navigation",
|
||||
"allow-popups-to-escape-sandbox",
|
||||
],
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen'],
|
||||
iframeAllowExtras: ["clipboard-write", "fullscreen"],
|
||||
// optional config to enforce a particular referrerPolicy
|
||||
referrerPolicy: 'same-origin',
|
||||
referrerPolicy: "same-origin",
|
||||
// optional callback to customize permalink URLs
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
|
||||
});
|
||||
@@ -163,13 +163,13 @@ Use the `themeMode` URL parameter to control the embedded dashboard's initial co
|
||||
|
||||
```js
|
||||
embedDashboard({
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
id: "abc123",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
dashboardUiConfig: {
|
||||
urlParams: {
|
||||
themeMode: 'dark', // 'dark' | 'system' | 'default' (default: 'default')
|
||||
themeMode: "dark", // 'dark' | 'system' | 'default' (default: 'default')
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -193,7 +193,7 @@ To pass additional sandbox attributes you can use `iframeSandboxExtras`:
|
||||
|
||||
```js
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'];
|
||||
iframeSandboxExtras: ["allow-top-navigation", "allow-popups-to-escape-sandbox"];
|
||||
```
|
||||
|
||||
### Permissions Policy
|
||||
@@ -202,7 +202,7 @@ To enable specific browser features within the embedded iframe, use `iframeAllow
|
||||
|
||||
```js
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen'];
|
||||
iframeAllowExtras: ["clipboard-write", "fullscreen"];
|
||||
```
|
||||
|
||||
Common permissions you might need:
|
||||
@@ -225,9 +225,9 @@ When users click share buttons inside an embedded dashboard, Superset generates
|
||||
|
||||
```js
|
||||
embedDashboard({
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
id: "abc123",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
|
||||
// Customize permalink URLs
|
||||
@@ -245,9 +245,9 @@ To restore the dashboard state from a permalink in your app:
|
||||
const permalinkKey = routeParams.key;
|
||||
|
||||
embedDashboard({
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
id: "abc123",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
|
||||
dashboardUiConfig: {
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
presets: [
|
||||
"@babel/preset-typescript",
|
||||
"@babel/preset-env"
|
||||
],
|
||||
presets: ["@babel/preset-typescript", "@babel/preset-env"],
|
||||
sourceMaps: true,
|
||||
};
|
||||
|
||||
10769
superset-embedded-sdk/package-lock.json
generated
10769
superset-embedded-sdk/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc && babel src --out-dir lib --extensions '.ts,.tsx' && webpack --mode production",
|
||||
"ci:release": "node ./release-if-necessary.js",
|
||||
"test": "jest"
|
||||
"test": "vitest --run --dir src"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 3 chrome versions",
|
||||
@@ -41,12 +41,11 @@
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/node": "^25.4.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"jest": "^29.7.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
"webpack": "^5.94.0",
|
||||
"webpack-cli": "^5.1.4"
|
||||
},
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const { name, version } = require('./package.json');
|
||||
const { execSync } = require("child_process");
|
||||
const { name, version } = require("./package.json");
|
||||
|
||||
function log(...args) {
|
||||
console.log('[embedded-sdk-release]', ...args);
|
||||
console.log("[embedded-sdk-release]", ...args);
|
||||
}
|
||||
|
||||
function logError(...args) {
|
||||
console.error('[embedded-sdk-release]', ...args);
|
||||
console.error("[embedded-sdk-release]", ...args);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
@@ -38,13 +38,13 @@ function logError(...args) {
|
||||
const { status } = await fetch(packageUrl);
|
||||
|
||||
if (status === 200) {
|
||||
log('version already exists on npm, exiting');
|
||||
log("version already exists on npm, exiting");
|
||||
} else if (status === 404) {
|
||||
log('release required, building');
|
||||
log("release required, building");
|
||||
try {
|
||||
execSync('npm run build', { stdio: 'pipe' });
|
||||
log('build successful, publishing')
|
||||
execSync('npm publish --access public', { stdio: 'pipe' });
|
||||
execSync("npm run build", { stdio: "pipe" });
|
||||
log("build successful, publishing");
|
||||
execSync("npm publish --access public", { stdio: "pipe" });
|
||||
log(`published ${version} to npm`);
|
||||
} catch (err) {
|
||||
// npm writes failure details to stderr (auth/permission/registry
|
||||
@@ -52,7 +52,7 @@ function logError(...args) {
|
||||
// the real cause in CI logs.
|
||||
if (err.stdout) console.error(String(err.stdout));
|
||||
if (err.stderr) console.error(String(err.stderr));
|
||||
logError('Encountered an error, details should be above');
|
||||
logError("Encountered an error, details should be above");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
*/
|
||||
|
||||
export const IFRAME_COMMS_MESSAGE_TYPE = "__embedded_comms__";
|
||||
export const DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY: { [index: string]: any } = {
|
||||
export const DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY: {
|
||||
[index: string]: any;
|
||||
} = {
|
||||
visible: "show_filters",
|
||||
expanded: "expand_filters",
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,22 +24,23 @@ import {
|
||||
DEFAULT_TOKEN_EXP_MS,
|
||||
DEFAULT_TOKEN_REFRESH_RETRY_MS,
|
||||
} from "./guestTokenRefresh";
|
||||
import { afterAll, beforeAll, it, expect, describe, vi } from "vitest";
|
||||
|
||||
describe("guest token refresh", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date("2022-03-03 01:00"));
|
||||
jest.spyOn(global, "setTimeout");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2022-03-03 01:00"));
|
||||
vi.spyOn(globalThis, "setTimeout");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function makeFakeJWT(claims: any) {
|
||||
// not a valid jwt, but close enough for this code
|
||||
const tokenifiedClaims = Buffer.from(JSON.stringify(claims)).toString(
|
||||
"base64"
|
||||
"base64",
|
||||
);
|
||||
return `abc.${tokenifiedClaims}.xyz`;
|
||||
}
|
||||
|
||||
@@ -18,17 +18,23 @@
|
||||
*/
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
export const REFRESH_TIMING_BUFFER_MS = 5000 // refresh guest token early to avoid failed superset requests
|
||||
export const MIN_REFRESH_WAIT_MS = 10000 // avoid blasting requests as fast as the cpu can handle
|
||||
export const DEFAULT_TOKEN_EXP_MS = 300000 // (5 min) used only when parsing guest token exp fails
|
||||
export const DEFAULT_TOKEN_REFRESH_RETRY_MS = 10000 // wait before retrying a failed/timed-out token refresh
|
||||
export const REFRESH_TIMING_BUFFER_MS = 5000; // refresh guest token early to avoid failed superset requests
|
||||
export const MIN_REFRESH_WAIT_MS = 10000; // avoid blasting requests as fast as the cpu can handle
|
||||
export const DEFAULT_TOKEN_EXP_MS = 300000; // (5 min) used only when parsing guest token exp fails
|
||||
export const DEFAULT_TOKEN_REFRESH_RETRY_MS = 10000; // wait before retrying a failed/timed-out token refresh
|
||||
|
||||
// when do we refresh the guest token?
|
||||
export function getGuestTokenRefreshTiming(currentGuestToken: string) {
|
||||
const parsedJwt = jwtDecode<Record<string, any>>(currentGuestToken);
|
||||
// if exp is int, it is in seconds, but Date() takes milliseconds
|
||||
const exp = new Date(/[^0-9\.]/g.test(parsedJwt.exp) ? parsedJwt.exp : parseFloat(parsedJwt.exp) * 1000);
|
||||
const isValidDate = exp.toString() !== 'Invalid Date';
|
||||
const ttl = isValidDate ? Math.max(MIN_REFRESH_WAIT_MS, exp.getTime() - Date.now()) : DEFAULT_TOKEN_EXP_MS;
|
||||
const exp = new Date(
|
||||
/[^0-9\.]/g.test(parsedJwt.exp)
|
||||
? parsedJwt.exp
|
||||
: parseFloat(parsedJwt.exp) * 1000,
|
||||
);
|
||||
const isValidDate = exp.toString() !== "Invalid Date";
|
||||
const ttl = isValidDate
|
||||
? Math.max(MIN_REFRESH_WAIT_MS, exp.getTime() - Date.now())
|
||||
: DEFAULT_TOKEN_EXP_MS;
|
||||
return ttl - REFRESH_TIMING_BUFFER_MS;
|
||||
}
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
import {
|
||||
DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY,
|
||||
IFRAME_COMMS_MESSAGE_TYPE,
|
||||
} from './const';
|
||||
} from "./const";
|
||||
|
||||
// We can swap this out for the actual switchboard package once it gets published
|
||||
import { Switchboard } from '@superset-ui/switchboard';
|
||||
import { Switchboard } from "@superset-ui/switchboard";
|
||||
import {
|
||||
getGuestTokenRefreshTiming,
|
||||
DEFAULT_TOKEN_REFRESH_RETRY_MS,
|
||||
} from './guestTokenRefresh';
|
||||
import { withTimeout } from './withTimeout';
|
||||
} from "./guestTokenRefresh";
|
||||
import { withTimeout } from "./withTimeout";
|
||||
|
||||
/**
|
||||
* The function to fetch a guest token from your Host App's backend server.
|
||||
@@ -97,7 +97,7 @@ export type ObserveDataMaskCallbackFn = (
|
||||
nativeFiltersChanged: boolean;
|
||||
},
|
||||
) => void;
|
||||
export type ThemeMode = 'default' | 'dark' | 'system';
|
||||
export type ThemeMode = "default" | "dark" | "system";
|
||||
|
||||
/**
|
||||
* Callback to resolve permalink URLs.
|
||||
@@ -113,12 +113,12 @@ export type EmbeddedDashboard = {
|
||||
unmount: () => void;
|
||||
getDashboardPermalink: (anchor: string) => Promise<string>;
|
||||
getActiveTabs: () => Promise<string[]>;
|
||||
observeDataMask: (
|
||||
callbackFn: ObserveDataMaskCallbackFn,
|
||||
) => void;
|
||||
observeDataMask: (callbackFn: ObserveDataMaskCallbackFn) => void;
|
||||
getDataMask: () => Promise<Record<string, any>>;
|
||||
getChartStates: () => Promise<Record<string, any>>;
|
||||
getChartDataPayloads: (params?: { chartId?: number }) => Promise<Record<string, any>>;
|
||||
getChartDataPayloads: (params?: {
|
||||
chartId?: number;
|
||||
}) => Promise<Record<string, any>>;
|
||||
setThemeConfig: (themeConfig: Record<string, any>) => void;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
};
|
||||
@@ -133,7 +133,7 @@ export async function embedDashboard({
|
||||
fetchGuestToken,
|
||||
dashboardUiConfig,
|
||||
debug = false,
|
||||
iframeTitle = 'Embedded Dashboard',
|
||||
iframeTitle = "Embedded Dashboard",
|
||||
iframeSandboxExtras = [],
|
||||
iframeAllowExtras = [],
|
||||
referrerPolicy,
|
||||
@@ -152,13 +152,13 @@ export async function embedDashboard({
|
||||
return withTimeout(
|
||||
fetchGuestToken(),
|
||||
guestTokenFetchTimeoutMs,
|
||||
'fetchGuestToken',
|
||||
"fetchGuestToken",
|
||||
);
|
||||
}
|
||||
|
||||
log('embedding');
|
||||
log("embedding");
|
||||
|
||||
if (supersetDomain.endsWith('/')) {
|
||||
if (supersetDomain.endsWith("/")) {
|
||||
supersetDomain = supersetDomain.slice(0, -1);
|
||||
}
|
||||
|
||||
@@ -185,15 +185,15 @@ export async function embedDashboard({
|
||||
}
|
||||
|
||||
async function mountIframe(): Promise<Switchboard> {
|
||||
return new Promise(resolve => {
|
||||
const iframe = document.createElement('iframe');
|
||||
return new Promise((resolve) => {
|
||||
const iframe = document.createElement("iframe");
|
||||
const dashboardConfigUrlParams = dashboardUiConfig
|
||||
? { uiConfig: `${calculateConfig()}` }
|
||||
: undefined;
|
||||
const filterConfig = dashboardUiConfig?.filters || {};
|
||||
const filterConfigKeys = Object.keys(filterConfig);
|
||||
const filterConfigUrlParams = Object.fromEntries(
|
||||
filterConfigKeys.map(key => [
|
||||
filterConfigKeys.map((key) => [
|
||||
DASHBOARD_UI_FILTER_CONFIG_URL_PARAM_KEY[key],
|
||||
filterConfig[key],
|
||||
]),
|
||||
@@ -206,16 +206,16 @@ export async function embedDashboard({
|
||||
...dashboardUiConfig?.urlParams,
|
||||
};
|
||||
const urlParamsString = Object.keys(urlParams).length
|
||||
? '?' + new URLSearchParams(urlParams).toString()
|
||||
: '';
|
||||
? "?" + new URLSearchParams(urlParams).toString()
|
||||
: "";
|
||||
|
||||
// set up the iframe's sandbox configuration
|
||||
iframe.sandbox.add('allow-same-origin'); // needed for postMessage to work
|
||||
iframe.sandbox.add('allow-scripts'); // obviously the iframe needs scripts
|
||||
iframe.sandbox.add('allow-presentation'); // for fullscreen charts
|
||||
iframe.sandbox.add('allow-downloads'); // for downloading charts as image
|
||||
iframe.sandbox.add('allow-forms'); // for forms to submit
|
||||
iframe.sandbox.add('allow-popups'); // for exporting charts as csv
|
||||
iframe.sandbox.add("allow-same-origin"); // needed for postMessage to work
|
||||
iframe.sandbox.add("allow-scripts"); // obviously the iframe needs scripts
|
||||
iframe.sandbox.add("allow-presentation"); // for fullscreen charts
|
||||
iframe.sandbox.add("allow-downloads"); // for downloading charts as image
|
||||
iframe.sandbox.add("allow-forms"); // for forms to submit
|
||||
iframe.sandbox.add("allow-popups"); // for exporting charts as csv
|
||||
// additional sandbox props
|
||||
iframeSandboxExtras.forEach((key: string) => {
|
||||
iframe.sandbox.add(key);
|
||||
@@ -226,7 +226,7 @@ export async function embedDashboard({
|
||||
}
|
||||
|
||||
// add the event listener before setting src, to be 100% sure that we capture the load event
|
||||
iframe.addEventListener('load', () => {
|
||||
iframe.addEventListener("load", () => {
|
||||
// MessageChannel allows us to send and receive messages smoothly between our window and the iframe
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Channel_Messaging_API
|
||||
const commsChannel = new MessageChannel();
|
||||
@@ -237,35 +237,35 @@ export async function embedDashboard({
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
|
||||
// we know the content window isn't null because we are in the load event handler.
|
||||
iframe.contentWindow!.postMessage(
|
||||
{ type: IFRAME_COMMS_MESSAGE_TYPE, handshake: 'port transfer' },
|
||||
{ type: IFRAME_COMMS_MESSAGE_TYPE, handshake: "port transfer" },
|
||||
supersetDomain,
|
||||
[theirPort],
|
||||
);
|
||||
log('sent message channel to the iframe');
|
||||
log("sent message channel to the iframe");
|
||||
|
||||
// return our port from the promise
|
||||
resolve(
|
||||
new Switchboard({
|
||||
port: ourPort,
|
||||
name: 'superset-embedded-sdk',
|
||||
name: "superset-embedded-sdk",
|
||||
debug,
|
||||
}),
|
||||
);
|
||||
});
|
||||
iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`;
|
||||
iframe.title = iframeTitle;
|
||||
iframe.style.background = 'transparent';
|
||||
iframe.style.background = "transparent";
|
||||
// Permissions Policy features the embedded dashboard relies on. Modern
|
||||
// browsers gate these APIs on the iframe's `allow` attribute regardless
|
||||
// of sandbox flags, so we include them by default. Host apps can extend
|
||||
// the list via `iframeAllowExtras`.
|
||||
const allowFeatures = Array.from(
|
||||
new Set(['fullscreen', 'clipboard-write', ...iframeAllowExtras]),
|
||||
new Set(["fullscreen", "clipboard-write", ...iframeAllowExtras]),
|
||||
);
|
||||
iframe.setAttribute('allow', allowFeatures.join('; '));
|
||||
iframe.setAttribute("allow", allowFeatures.join("; "));
|
||||
//@ts-ignore
|
||||
mountPoint.replaceChildren(iframe);
|
||||
log('placed the iframe');
|
||||
log("placed the iframe");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,8 +285,8 @@ export async function embedDashboard({
|
||||
throw err;
|
||||
}
|
||||
|
||||
ourPort.emit('guestToken', { guestToken });
|
||||
log('sent guest token');
|
||||
ourPort.emit("guestToken", { guestToken });
|
||||
log("sent guest token");
|
||||
|
||||
// Track the pending refresh timer so it can be cancelled on unmount, and
|
||||
// stop the cycle once unmounted so it cannot leak across mount/unmount cycles.
|
||||
@@ -298,7 +298,7 @@ export async function embedDashboard({
|
||||
try {
|
||||
const newGuestToken = await fetchGuestTokenWithTimeout();
|
||||
if (unmounted) return;
|
||||
ourPort.emit('guestToken', { guestToken: newGuestToken });
|
||||
ourPort.emit("guestToken", { guestToken: newGuestToken });
|
||||
refreshTimer = setTimeout(
|
||||
refreshGuestToken,
|
||||
getGuestTokenRefreshTiming(newGuestToken),
|
||||
@@ -307,7 +307,7 @@ export async function embedDashboard({
|
||||
// A transient fetch failure or timeout must not permanently stop the
|
||||
// refresh cycle. Log it and retry so the session can recover once the
|
||||
// host callback succeeds again.
|
||||
log('failed to refresh guest token, will retry:', err);
|
||||
log("failed to refresh guest token, will retry:", err);
|
||||
if (unmounted) return;
|
||||
refreshTimer = setTimeout(
|
||||
refreshGuestToken,
|
||||
@@ -325,7 +325,7 @@ export async function embedDashboard({
|
||||
// Returns null if no callback provided or on error, allowing iframe to use default URL
|
||||
ourPort.start();
|
||||
ourPort.defineMethod(
|
||||
'resolvePermalinkUrl',
|
||||
"resolvePermalinkUrl",
|
||||
async ({ key }: { key: string }): Promise<string | null> => {
|
||||
if (!resolvePermalinkUrl) {
|
||||
return null;
|
||||
@@ -333,14 +333,14 @@ export async function embedDashboard({
|
||||
try {
|
||||
return await resolvePermalinkUrl({ key });
|
||||
} catch (error) {
|
||||
log('Error in resolvePermalinkUrl callback:', error);
|
||||
log("Error in resolvePermalinkUrl callback:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function unmount() {
|
||||
log('unmounting');
|
||||
log("unmounting");
|
||||
unmounted = true;
|
||||
if (refreshTimer !== undefined) {
|
||||
clearTimeout(refreshTimer);
|
||||
@@ -350,24 +350,25 @@ export async function embedDashboard({
|
||||
mountPoint.replaceChildren();
|
||||
}
|
||||
|
||||
const getScrollSize = () => ourPort.get<Size>('getScrollSize');
|
||||
const getScrollSize = () => ourPort.get<Size>("getScrollSize");
|
||||
const getDashboardPermalink = (anchor: string) =>
|
||||
ourPort.get<string>('getDashboardPermalink', { anchor });
|
||||
const getActiveTabs = () => ourPort.get<string[]>('getActiveTabs');
|
||||
const getDataMask = () => ourPort.get<Record<string, any>>('getDataMask');
|
||||
const getChartStates = () => ourPort.get<Record<string, any>>('getChartStates');
|
||||
ourPort.get<string>("getDashboardPermalink", { anchor });
|
||||
const getActiveTabs = () => ourPort.get<string[]>("getActiveTabs");
|
||||
const getDataMask = () => ourPort.get<Record<string, any>>("getDataMask");
|
||||
const getChartStates = () =>
|
||||
ourPort.get<Record<string, any>>("getChartStates");
|
||||
const getChartDataPayloads = (params?: { chartId?: number }) =>
|
||||
ourPort.get<Record<string, any>>('getChartDataPayloads', params);
|
||||
const observeDataMask = (
|
||||
callbackFn: ObserveDataMaskCallbackFn,
|
||||
) => {
|
||||
ourPort.defineMethod('observeDataMask', callbackFn);
|
||||
ourPort.get<Record<string, any>>("getChartDataPayloads", params);
|
||||
const observeDataMask = (callbackFn: ObserveDataMaskCallbackFn) => {
|
||||
ourPort.defineMethod("observeDataMask", callbackFn);
|
||||
};
|
||||
// TODO: Add proper types once theming branch is merged
|
||||
const setThemeConfig = async (themeConfig: Record<string, any>): Promise<void> => {
|
||||
const setThemeConfig = async (
|
||||
themeConfig: Record<string, any>,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
ourPort.emit('setThemeConfig', { themeConfig });
|
||||
log('Theme config sent successfully (or at least message dispatched)');
|
||||
ourPort.emit("setThemeConfig", { themeConfig });
|
||||
log("Theme config sent successfully (or at least message dispatched)");
|
||||
} catch (error) {
|
||||
log(
|
||||
'Error sending theme config. Ensure the iframe side implements the "setThemeConfig" method.',
|
||||
@@ -378,7 +379,7 @@ export async function embedDashboard({
|
||||
|
||||
const setThemeMode = (mode: ThemeMode): void => {
|
||||
try {
|
||||
ourPort.emit('setThemeMode', { mode });
|
||||
ourPort.emit("setThemeMode", { mode });
|
||||
log(`Theme mode set to: ${mode}`);
|
||||
} catch (error) {
|
||||
log(
|
||||
|
||||
@@ -18,22 +18,23 @@
|
||||
*/
|
||||
|
||||
import { withTimeout } from "./withTimeout";
|
||||
import { test, expect } from "vitest";
|
||||
|
||||
test("resolves with the value when the promise settles in time", async () => {
|
||||
await expect(withTimeout(Promise.resolve("ok"), 1000, "fetch")).resolves.toBe(
|
||||
"ok"
|
||||
"ok",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects when the promise does not settle within the timeout", async () => {
|
||||
const never = new Promise<string>(() => {});
|
||||
await expect(withTimeout(never, 10, "fetch")).rejects.toThrow(
|
||||
/fetch did not resolve within 10ms/
|
||||
/fetch did not resolve within 10ms/,
|
||||
);
|
||||
});
|
||||
|
||||
test("passes the promise through unchanged when the timeout is disabled", async () => {
|
||||
await expect(withTimeout(Promise.resolve("ok"), 0, "fetch")).resolves.toBe(
|
||||
"ok"
|
||||
"ok",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// syntax rules
|
||||
"strict": true,
|
||||
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// environment
|
||||
"target": "es6",
|
||||
@@ -13,7 +13,9 @@
|
||||
// output
|
||||
"outDir": "./dist",
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true
|
||||
"declaration": true,
|
||||
|
||||
"types": ["node"]
|
||||
},
|
||||
|
||||
"include": [
|
||||
@@ -21,7 +23,6 @@
|
||||
],
|
||||
|
||||
"exclude": [
|
||||
"tests",
|
||||
"dist",
|
||||
"lib",
|
||||
"node_modules"
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
entry: './src/index.ts',
|
||||
entry: "./src/index.ts",
|
||||
output: {
|
||||
filename: 'index.js',
|
||||
path: path.resolve(__dirname, 'bundle'),
|
||||
filename: "index.js",
|
||||
path: path.resolve(__dirname, "bundle"),
|
||||
|
||||
// this exposes the library's exports under a global variable
|
||||
library: {
|
||||
name: "supersetEmbeddedSdk",
|
||||
type: "umd"
|
||||
}
|
||||
type: "umd",
|
||||
},
|
||||
},
|
||||
devtool: "source-map",
|
||||
module: {
|
||||
@@ -38,12 +38,12 @@ module.exports = {
|
||||
test: /\.[tj]s$/,
|
||||
// babel-loader is faster than ts-loader because it ignores types.
|
||||
// We do type checking in a separate process, so that's fine.
|
||||
use: 'babel-loader',
|
||||
use: "babel-loader",
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js'],
|
||||
extensions: [".ts", ".js"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DATABASE_LIST } from 'cypress/utils/urls';
|
||||
|
||||
function closeModal() {
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-test="database-modal"]').length) {
|
||||
cy.get('[aria-label="Close"]').eq(1).click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('Add database', () => {
|
||||
before(() => {
|
||||
cy.visit(DATABASE_LIST);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cy.intercept('POST', '**/api/v1/database/validate_parameters/**').as(
|
||||
'validateParams',
|
||||
);
|
||||
cy.intercept('POST', '**/api/v1/database/').as('createDb');
|
||||
|
||||
closeModal();
|
||||
cy.getBySel('btn-create-database').click();
|
||||
});
|
||||
|
||||
it('should open dynamic form', () => {
|
||||
cy.get('.preferred > :nth-child(1)').click();
|
||||
|
||||
cy.get('input[name="host"]').should('have.value', '');
|
||||
cy.get('input[name="port"]').should('have.value', '');
|
||||
cy.get('input[name="database"]').should('have.value', '');
|
||||
cy.get('input[name="username"]').should('have.value', '');
|
||||
cy.get('input[name="password"]').should('have.value', '');
|
||||
cy.get('input[name="database_name"]').should('have.value', '');
|
||||
});
|
||||
|
||||
it('should open sqlalchemy form', () => {
|
||||
cy.get('.preferred > :nth-child(1)').click();
|
||||
cy.getBySel('sqla-connect-btn').click();
|
||||
|
||||
cy.getBySel('database-name-input').should('be.visible');
|
||||
cy.getBySel('sqlalchemy-uri-input').should('be.visible');
|
||||
});
|
||||
|
||||
it('show error alerts on dynamic form for bad host', () => {
|
||||
cy.get('.preferred > :nth-child(1)').click();
|
||||
|
||||
cy.get('input[name="host"]').type('badhost', { force: true });
|
||||
cy.get('input[name="port"]').type('5432', { force: true });
|
||||
cy.get('input[name="username"]').type('testusername', { force: true });
|
||||
cy.get('input[name="database"]').type('testdb', { force: true });
|
||||
cy.get('input[name="password"]').type('testpass', { force: true });
|
||||
|
||||
cy.get('body').click(0, 0);
|
||||
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
|
||||
cy.getBySel('btn-submit-connection').should('not.be.disabled');
|
||||
cy.getBySel('btn-submit-connection').click({ force: true });
|
||||
|
||||
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
|
||||
cy.wait('@createDb', { timeout: 60000 }).then(() => {
|
||||
cy.contains(
|
||||
'.ant-form-item-explain-error',
|
||||
"The hostname provided can't be resolved",
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('show error alerts on dynamic form for bad port', () => {
|
||||
cy.get('.preferred > :nth-child(1)').click();
|
||||
|
||||
cy.get('input[name="host"]').type('localhost', { force: true });
|
||||
cy.get('body').click(0, 0);
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
|
||||
cy.get('input[name="port"]').type('5430', { force: true });
|
||||
cy.get('input[name="database"]').type('testdb', { force: true });
|
||||
cy.get('input[name="username"]').type('testusername', { force: true });
|
||||
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
|
||||
cy.get('input[name="password"]').type('testpass', { force: true });
|
||||
cy.wait('@validateParams');
|
||||
|
||||
cy.getBySel('btn-submit-connection').should('not.be.disabled');
|
||||
cy.getBySel('btn-submit-connection').click({ force: true });
|
||||
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
|
||||
cy.get('body').click(0, 0);
|
||||
cy.getBySel('btn-submit-connection').click({ force: true });
|
||||
cy.wait('@createDb', { timeout: 60000 }).then(() => {
|
||||
cy.contains(
|
||||
'.ant-form-item-explain-error',
|
||||
'The port is closed',
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
523
superset-frontend/cypress-base/package-lock.json
generated
523
superset-frontend/cypress-base/package-lock.json
generated
@@ -27,17 +27,6 @@
|
||||
"tscw-config": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz",
|
||||
"integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.12.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
|
||||
@@ -48,33 +37,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.21.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.4.tgz",
|
||||
"integrity": "sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.5.tgz",
|
||||
"integrity": "sha512-/BBMw4EvjmyquN5O+t5eh0+YqB3XXJkYD2cjKpYtWOfFy4lQ4UozNSmxAcWT8r2XtZs0ewG+zrfsqeR15i1ajA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.1.0",
|
||||
"@babel/code-frame": "^7.16.7",
|
||||
"@babel/generator": "^7.17.3",
|
||||
"@babel/helper-compilation-targets": "^7.16.7",
|
||||
"@babel/helper-module-transforms": "^7.16.7",
|
||||
"@babel/helpers": "^7.17.2",
|
||||
"@babel/parser": "^7.17.3",
|
||||
"@babel/template": "^7.16.7",
|
||||
"@babel/traverse": "^7.17.3",
|
||||
"@babel/types": "^7.17.0",
|
||||
"convert-source-map": "^1.7.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
"json5": "^2.1.2",
|
||||
"semver": "^6.3.0"
|
||||
"json5": "^2.2.3",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -85,23 +76,94 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/@babel/code-frame": {
|
||||
"version": "7.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
|
||||
"integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.16.7"
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"node_modules/@babel/core/node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -139,6 +201,7 @@
|
||||
"version": "7.21.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.4.tgz",
|
||||
"integrity": "sha512-Fa0tTuOXZ1iL8IeDFUWCzjZcn+sJGd9RZdH9esYVjEejGmzf+FFYQpMi/kZUk2kPy/q1H3/GPw7np8qar/stfg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.21.4",
|
||||
"@babel/helper-validator-option": "^7.21.0",
|
||||
@@ -157,6 +220,7 @@
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
@@ -164,7 +228,8 @@
|
||||
"node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.21.4",
|
||||
@@ -256,9 +321,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -279,6 +345,7 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
@@ -291,6 +358,7 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
@@ -396,25 +464,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
|
||||
"integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -435,13 +506,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
|
||||
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.10"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -451,6 +522,7 @@
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
|
||||
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"chalk": "^2.4.2",
|
||||
@@ -461,11 +533,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -1593,24 +1666,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template/node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -1619,16 +1694,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1636,11 +1712,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse/node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -1649,12 +1726,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2093,6 +2171,16 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
|
||||
@@ -3353,6 +3441,7 @@
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
@@ -3366,6 +3455,7 @@
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^1.9.0"
|
||||
},
|
||||
@@ -3491,6 +3581,7 @@
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
@@ -3498,7 +3589,8 @@
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "1.4.0",
|
||||
@@ -4984,6 +5076,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
@@ -7878,6 +7971,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^3.0.0"
|
||||
},
|
||||
@@ -8770,14 +8864,6 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz",
|
||||
"integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==",
|
||||
"requires": {
|
||||
"@jridgewell/trace-mapping": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"@babel/code-frame": {
|
||||
"version": "7.12.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
|
||||
@@ -8788,49 +8874,100 @@
|
||||
}
|
||||
},
|
||||
"@babel/compat-data": {
|
||||
"version": "7.21.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.4.tgz",
|
||||
"integrity": "sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g=="
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="
|
||||
},
|
||||
"@babel/core": {
|
||||
"version": "7.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.5.tgz",
|
||||
"integrity": "sha512-/BBMw4EvjmyquN5O+t5eh0+YqB3XXJkYD2cjKpYtWOfFy4lQ4UozNSmxAcWT8r2XtZs0ewG+zrfsqeR15i1ajA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"requires": {
|
||||
"@ampproject/remapping": "^2.1.0",
|
||||
"@babel/code-frame": "^7.16.7",
|
||||
"@babel/generator": "^7.17.3",
|
||||
"@babel/helper-compilation-targets": "^7.16.7",
|
||||
"@babel/helper-module-transforms": "^7.16.7",
|
||||
"@babel/helpers": "^7.17.2",
|
||||
"@babel/parser": "^7.17.3",
|
||||
"@babel/template": "^7.16.7",
|
||||
"@babel/traverse": "^7.17.3",
|
||||
"@babel/types": "^7.17.0",
|
||||
"convert-source-map": "^1.7.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
"json5": "^2.1.2",
|
||||
"semver": "^6.3.0"
|
||||
"json5": "^2.2.3",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
|
||||
"integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.16.7"
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-compilation-targets": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"requires": {
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-module-imports": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"requires": {
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
}
|
||||
},
|
||||
"@babel/helper-module-transforms": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"requires": {
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
}
|
||||
},
|
||||
"convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||
},
|
||||
"lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"requires": {
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"requires": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -8859,6 +8996,7 @@
|
||||
"version": "7.21.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.4.tgz",
|
||||
"integrity": "sha512-Fa0tTuOXZ1iL8IeDFUWCzjZcn+sJGd9RZdH9esYVjEejGmzf+FFYQpMi/kZUk2kPy/q1H3/GPw7np8qar/stfg==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@babel/compat-data": "^7.21.4",
|
||||
"@babel/helper-validator-option": "^7.21.0",
|
||||
@@ -8871,6 +9009,7 @@
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
@@ -8878,7 +9017,8 @@
|
||||
"yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"peer": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -8948,9 +9088,9 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="
|
||||
},
|
||||
"@babel/helper-member-expression-to-functions": {
|
||||
"version": "7.21.0",
|
||||
@@ -8965,6 +9105,7 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
@@ -8974,6 +9115,7 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
@@ -9049,19 +9191,19 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="
|
||||
},
|
||||
"@babel/helper-validator-option": {
|
||||
"version": "7.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
|
||||
"integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ=="
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="
|
||||
},
|
||||
"@babel/helper-wrap-function": {
|
||||
"version": "7.20.5",
|
||||
@@ -9076,18 +9218,19 @@
|
||||
}
|
||||
},
|
||||
"@babel/helpers": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
|
||||
"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"requires": {
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.10"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
|
||||
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"chalk": "^2.4.2",
|
||||
@@ -9095,11 +9238,11 @@
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
|
||||
@@ -9851,21 +9994,21 @@
|
||||
}
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
@@ -9873,25 +10016,25 @@
|
||||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
@@ -9899,12 +10042,12 @@
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"requires": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
}
|
||||
},
|
||||
"@colors/colors": {
|
||||
@@ -10226,7 +10369,7 @@
|
||||
"camelcase": "^5.3.1",
|
||||
"find-up": "^4.1.0",
|
||||
"get-package-type": "^0.1.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"js-yaml": "4.1.1",
|
||||
"resolve-from": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -10236,7 +10379,8 @@
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"requires": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -10258,6 +10402,15 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"requires": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"@jridgewell/resolve-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
|
||||
@@ -11317,6 +11470,7 @@
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
@@ -11327,6 +11481,7 @@
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"color-convert": "^1.9.0"
|
||||
}
|
||||
@@ -11419,6 +11574,7 @@
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
@@ -11426,7 +11582,8 @@
|
||||
"color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
|
||||
"peer": true
|
||||
},
|
||||
"colorette": {
|
||||
"version": "1.4.0",
|
||||
@@ -12546,7 +12703,8 @@
|
||||
"has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
|
||||
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
|
||||
"peer": true
|
||||
},
|
||||
"has-symbols": {
|
||||
"version": "1.1.0",
|
||||
@@ -12872,7 +13030,7 @@
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz",
|
||||
"integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==",
|
||||
"requires": {
|
||||
"@babel/core": "^7.7.5",
|
||||
"@babel/core": "^7.29.6",
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"semver": "^6.3.0"
|
||||
@@ -14580,6 +14738,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"has-flag": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"tscw-config": "^1.1.2"
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core": "^7.29.6",
|
||||
"cypress": {
|
||||
"form-data": "^2.3.4"
|
||||
},
|
||||
|
||||
812
superset-frontend/package-lock.json
generated
812
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -198,7 +198,7 @@
|
||||
"memoize-one": "^6.0.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"nanoid": "^5.1.14",
|
||||
"ol": "^10.9.0",
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
@@ -283,7 +283,7 @@
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -350,7 +350,7 @@
|
||||
"process": "^0.11.10",
|
||||
"react-dnd-test-backend": "^16.0.1",
|
||||
"react-refresh": "^0.18.0",
|
||||
"react-resizable": "^4.0.1",
|
||||
"react-resizable": "^4.0.2",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -388,6 +388,10 @@
|
||||
"overrides": {
|
||||
"uuid": "$uuid",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"esbuild": "^0.28.1",
|
||||
"http-proxy-middleware": "^2.0.10",
|
||||
"tar": "^7.5.16",
|
||||
"puppeteer": "^22.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"underscore": "^1.13.7",
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.6.0",
|
||||
"react-draggable": "^4.7.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
@@ -77,7 +77,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
@@ -45,6 +45,7 @@ export const IconTooltip = forwardRef<HTMLElement, IconTooltipProps>(
|
||||
}}
|
||||
buttonStyle="link"
|
||||
className={`IconTooltip ${className}`}
|
||||
aria-label={tooltip ?? undefined}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
@@ -24,7 +24,10 @@ import { Modal } from '../Modal';
|
||||
export interface ModalTriggerProps {
|
||||
dialogClassName?: string;
|
||||
triggerNode: ReactNode;
|
||||
modalTitle?: string;
|
||||
// Accept ReactNode so callers can pass rich titles (e.g. a full
|
||||
// <ControlHeader> with description tooltip + validation badges +
|
||||
// warning icons). String remains valid for the common case.
|
||||
modalTitle?: ReactNode;
|
||||
modalBody?: ReactNode; // not required because it can be generated by beforeOpen
|
||||
modalFooter?: ReactNode;
|
||||
beforeOpen?: Function;
|
||||
@@ -110,7 +113,9 @@ export const ModalTrigger = forwardRef(
|
||||
className={className}
|
||||
show={showModal}
|
||||
onHide={close}
|
||||
name={modalTitle}
|
||||
// `name` is used for data-test / telemetry and must be a string;
|
||||
// `title` accepts arbitrary ReactNode for rich rendering.
|
||||
name={typeof modalTitle === 'string' ? modalTitle : undefined}
|
||||
title={modalTitle}
|
||||
footer={modalFooter}
|
||||
hideFooter={!modalFooter}
|
||||
|
||||
@@ -113,7 +113,7 @@ const AsyncSelect = forwardRef(
|
||||
allowClear,
|
||||
allowNewOptions = false,
|
||||
ariaLabel,
|
||||
autoClearSearchValue = false,
|
||||
autoClearSearchValue = true,
|
||||
fetchOnlyOnSearch,
|
||||
filterOption = true,
|
||||
header = null,
|
||||
@@ -267,6 +267,12 @@ const AsyncSelect = forwardRef(
|
||||
});
|
||||
fireOnChange();
|
||||
}
|
||||
if (autoClearSearchValue) {
|
||||
setInputValue('');
|
||||
if (fetchOnlyOnSearch) {
|
||||
setSelectOptions([]);
|
||||
}
|
||||
}
|
||||
onSelect?.(selectedItem, option);
|
||||
};
|
||||
|
||||
|
||||
@@ -404,7 +404,7 @@ AdvancedPlayground.args = {
|
||||
autoFocus: true,
|
||||
allowNewOptions: false,
|
||||
allowClear: false,
|
||||
autoClearSearchValue: false,
|
||||
autoClearSearchValue: true,
|
||||
allowSelectAll: true,
|
||||
disabled: false,
|
||||
invertSelection: false,
|
||||
|
||||
@@ -94,7 +94,7 @@ const Select = forwardRef(
|
||||
allowNewOptionsOnPaste = false,
|
||||
allowSelectAll = true,
|
||||
ariaLabel,
|
||||
autoClearSearchValue = false,
|
||||
autoClearSearchValue = true,
|
||||
filterOption = true,
|
||||
header = null,
|
||||
headerPosition = 'top',
|
||||
@@ -334,6 +334,11 @@ const Select = forwardRef(
|
||||
});
|
||||
fireOnChange();
|
||||
}
|
||||
if (autoClearSearchValue) {
|
||||
setInputValue('');
|
||||
setIsSearching(false);
|
||||
setVisibleOptions(fullSelectOptions);
|
||||
}
|
||||
onSelect?.(selectedItem, option);
|
||||
};
|
||||
|
||||
|
||||
@@ -33,10 +33,15 @@ test('should render', () => {
|
||||
|
||||
test('should render the pixel link when FF is on', () => {
|
||||
process.env.SCARF_ANALYTICS = 'true';
|
||||
render(<TelemetryPixel />);
|
||||
render(<TelemetryPixel version="1.2.3" sha="abc" build="42" />);
|
||||
|
||||
const image = document.querySelector('img[src*="scarf.sh"]');
|
||||
// Hits Scarf's static pixel directly, not the gateway redirect that browsers flag
|
||||
const image = document.querySelector('img[src^="https://static.scarf.sh/"]');
|
||||
expect(image).toBeInTheDocument();
|
||||
expect(image?.getAttribute('src')).toContain('version=1.2.3');
|
||||
expect(image?.getAttribute('src')).toContain('sha=abc');
|
||||
expect(image?.getAttribute('src')).toContain('build=42');
|
||||
expect(document.querySelector('img[src*="gateway.scarf.sh"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('should NOT render the pixel link when FF is off', () => {
|
||||
@@ -46,3 +51,19 @@ test('should NOT render the pixel link when FF is off', () => {
|
||||
const image = document.querySelector('img[src*="scarf.sh"]');
|
||||
expect(image).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should NOT render the pixel link when disabled at runtime', () => {
|
||||
process.env.SCARF_ANALYTICS = 'true';
|
||||
render(<TelemetryPixel enabled={false} />);
|
||||
|
||||
const image = document.querySelector('img[src*="scarf.sh"]');
|
||||
expect(image).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the pixel link when enabled at runtime', () => {
|
||||
process.env.SCARF_ANALYTICS = 'true';
|
||||
render(<TelemetryPixel enabled />);
|
||||
|
||||
const image = document.querySelector('img[src*="scarf.sh"]');
|
||||
expect(image).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -23,17 +23,25 @@ interface TelemetryPixelProps {
|
||||
version?: string;
|
||||
sha?: string;
|
||||
build?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a telemetry pixel component to capture anonymous, aggregated telemetry via Scarf.
|
||||
* This can be disabled by setting the SCARF_ANALYTICS environment variable to false.
|
||||
*
|
||||
* Telemetry can be disabled in two ways:
|
||||
* - At build time, by setting the SCARF_ANALYTICS environment variable to `false`
|
||||
* (inlined by webpack; only effective when building the frontend yourself).
|
||||
* - At runtime, by passing `enabled={false}`, which the app derives from the
|
||||
* `SCARF_ANALYTICS` backend config exposed via the bootstrap payload. This is
|
||||
* what allows opting out in pre-built images, where the build-time flag is fixed.
|
||||
*
|
||||
* @component
|
||||
* @param {TelemetryPixelProps} props - The props for the TelemetryPixel component.
|
||||
* @param {string} props.version - The version of Superset that's currently in use.
|
||||
* @param {string} props.sha - The SHA of Superset that's currently in use.
|
||||
* @param {string} props.build - The build of Superset that's currently in use.
|
||||
* @param {boolean} props.enabled - Runtime opt-out switch; when false the pixel is not rendered.
|
||||
* @returns {JSX.Element | null} The rendered TelemetryPixel component.
|
||||
*/
|
||||
|
||||
@@ -43,9 +51,18 @@ export const TelemetryPixel = ({
|
||||
version = 'unknownVersion',
|
||||
sha = 'unknownSHA',
|
||||
build = 'unknownBuild',
|
||||
enabled = true,
|
||||
}: TelemetryPixelProps): ReactElement | null => {
|
||||
const pixelPath = `https://apachesuperset.gateway.scarf.sh/pixel/${PIXEL_ID}/${version}/${sha}/${build}`;
|
||||
return process.env.SCARF_ANALYTICS === 'false' ? null : (
|
||||
// Use Scarf's native static pixel directly rather than the gateway redirect
|
||||
// (apachesuperset.gateway.scarf.sh), which some browsers/extensions flag as a
|
||||
// tracking redirect. The gateway route forwards to this same static endpoint.
|
||||
const pixelPath =
|
||||
`https://static.scarf.sh/a.png?x-pxid=${PIXEL_ID}` +
|
||||
`&version=${encodeURIComponent(version)}` +
|
||||
`&sha=${encodeURIComponent(sha)}` +
|
||||
`&build=${encodeURIComponent(build)}`;
|
||||
const disabled = !enabled || process.env.SCARF_ANALYTICS === 'false';
|
||||
return disabled ? null : (
|
||||
<img
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
src={pixelPath}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -344,6 +344,16 @@ export default function transformProps(
|
||||
data2,
|
||||
currencyCodeColumn,
|
||||
);
|
||||
const getAxisFormatterConfig = (axisIndex?: number) =>
|
||||
axisIndex === 1
|
||||
? {
|
||||
customFormatters: customFormattersSecondary,
|
||||
formatter: formatterSecondary,
|
||||
}
|
||||
: {
|
||||
customFormatters,
|
||||
formatter,
|
||||
};
|
||||
|
||||
const primarySeries = new Set<string>();
|
||||
const secondarySeries = new Set<string>();
|
||||
@@ -422,6 +432,8 @@ export default function transformProps(
|
||||
let [minSecondary, maxSecondary] = (yAxisBoundsSecondary || []).map(
|
||||
parseAxisBound,
|
||||
);
|
||||
const getAxisMax = (axisIndex?: number) =>
|
||||
axisIndex === 1 ? maxSecondary : yAxisMax;
|
||||
|
||||
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
|
||||
const inverted = invert(verboseMap);
|
||||
@@ -445,10 +457,11 @@ export default function transformProps(
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
|
||||
}
|
||||
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndex);
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
customFormatters,
|
||||
formatter,
|
||||
axisFormatterConfig.customFormatters,
|
||||
axisFormatterConfig.formatter,
|
||||
metrics,
|
||||
labelMap?.[seriesName]?.[0],
|
||||
!!contributionMode,
|
||||
@@ -480,7 +493,7 @@ export default function transformProps(
|
||||
formatter:
|
||||
seriesType === EchartsTimeseriesSeriesType.Bar
|
||||
? getOverMaxHiddenFormatter({
|
||||
max: yAxisMax,
|
||||
max: getAxisMax(yAxisIndex),
|
||||
formatter: seriesFormatter,
|
||||
})
|
||||
: seriesFormatter,
|
||||
@@ -518,10 +531,11 @@ export default function transformProps(
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
|
||||
}
|
||||
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndexB);
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
customFormattersSecondary,
|
||||
formatterSecondary,
|
||||
axisFormatterConfig.customFormatters,
|
||||
axisFormatterConfig.formatter,
|
||||
metricsB,
|
||||
labelMapB?.[seriesName]?.[0],
|
||||
!!contributionMode,
|
||||
@@ -554,7 +568,7 @@ export default function transformProps(
|
||||
formatter:
|
||||
seriesTypeB === EchartsTimeseriesSeriesType.Bar
|
||||
? getOverMaxHiddenFormatter({
|
||||
max: maxSecondary,
|
||||
max: getAxisMax(yAxisIndexB),
|
||||
formatter: seriesFormatter,
|
||||
})
|
||||
: seriesFormatter,
|
||||
|
||||
@@ -186,7 +186,9 @@ export default function transformProps(
|
||||
showLabels,
|
||||
showLabelsThreshold,
|
||||
showTotal,
|
||||
showNullValues,
|
||||
// Default to true so charts saved before this control existed keep
|
||||
// showing null values instead of silently hiding them on upgrade.
|
||||
showNullValues = true,
|
||||
sliceId,
|
||||
} = formData;
|
||||
const {
|
||||
|
||||
@@ -472,6 +472,7 @@ export function transformFormulaAnnotation(
|
||||
return {
|
||||
name,
|
||||
id: name,
|
||||
z: 10,
|
||||
itemStyle: {
|
||||
color: color || colorScale(name, sliceId),
|
||||
},
|
||||
@@ -565,6 +566,7 @@ export function transformIntervalAnnotation(
|
||||
id: `Interval - ${name}`,
|
||||
type: 'line',
|
||||
animation: false,
|
||||
z: 10,
|
||||
markArea: {
|
||||
silent: false,
|
||||
itemStyle: {
|
||||
@@ -660,6 +662,7 @@ export function transformEventAnnotation(
|
||||
id: `Event - ${name}`,
|
||||
type: 'line',
|
||||
animation: false,
|
||||
z: 10,
|
||||
markLine: {
|
||||
silent: false,
|
||||
symbol: 'none',
|
||||
@@ -705,6 +708,7 @@ export function transformTimeseriesAnnotation(
|
||||
type: 'line',
|
||||
id: name,
|
||||
name,
|
||||
z: 10,
|
||||
data,
|
||||
symbolSize: showMarkers ? markerSize : 0,
|
||||
itemStyle: computedStyle,
|
||||
|
||||
@@ -122,4 +122,8 @@ export const TOOLTIP_POINTER_MARGIN = 10;
|
||||
// from the edge of the window should the tooltip be kept
|
||||
export const TOOLTIP_OVERFLOW_MARGIN = 5;
|
||||
|
||||
// Minimum distance from the top of the chart container to keep the tooltip,
|
||||
// reserving space for annotation labels rendered at insideEndTop of markLines/markAreas
|
||||
export const TOOLTIP_TOP_CLEARANCE = 40;
|
||||
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
getColumnLabel,
|
||||
getMetricLabel,
|
||||
} from '@superset-ui/core';
|
||||
import { TOOLTIP_OVERFLOW_MARGIN, TOOLTIP_POINTER_MARGIN } from '../constants';
|
||||
import {
|
||||
TOOLTIP_OVERFLOW_MARGIN,
|
||||
TOOLTIP_POINTER_MARGIN,
|
||||
TOOLTIP_TOP_CLEARANCE,
|
||||
} from '../constants';
|
||||
import { Refs } from '../types';
|
||||
|
||||
export function getDefaultTooltip(refs: Refs) {
|
||||
@@ -107,18 +111,44 @@ export function getDefaultTooltip(refs: Refs) {
|
||||
}
|
||||
}
|
||||
|
||||
// Position tooltip above cursor, or below if no space
|
||||
yPos = mouseY - TOOLTIP_POINTER_MARGIN - effectiveTooltipHeight;
|
||||
// Mirror horizontal logic: position tooltip below cursor when in top half of chart,
|
||||
// above cursor when in bottom half. This prevents the tooltip from covering annotation
|
||||
// labels that appear at the top of the chart (markArea/markLine labels).
|
||||
const chartHeight = divRect?.height || viewportHeight;
|
||||
const cursorYInChart = canvasMousePos[1];
|
||||
const isInTopHalfOfChart = cursorYInChart < chartHeight / 2;
|
||||
|
||||
// The tooltip is overflowing past the top edge of the window
|
||||
if (yPos <= 0) {
|
||||
// Attempt to place the tooltip to the bottom of the mouse position
|
||||
if (isInTopHalfOfChart) {
|
||||
yPos = mouseY + TOOLTIP_POINTER_MARGIN;
|
||||
|
||||
// The tooltip is overflowing past the bottom edge of the window
|
||||
if (yPos + effectiveTooltipHeight >= viewportHeight)
|
||||
// Place the tooltip a fixed distance from the top edge of the window
|
||||
yPos = TOOLTIP_OVERFLOW_MARGIN;
|
||||
if (yPos + effectiveTooltipHeight >= viewportHeight) {
|
||||
yPos = mouseY - TOOLTIP_POINTER_MARGIN - effectiveTooltipHeight;
|
||||
|
||||
if (yPos <= 0) {
|
||||
yPos = TOOLTIP_OVERFLOW_MARGIN;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
yPos = mouseY - TOOLTIP_POINTER_MARGIN - effectiveTooltipHeight;
|
||||
|
||||
if (yPos <= 0) {
|
||||
yPos = mouseY + TOOLTIP_POINTER_MARGIN;
|
||||
|
||||
if (yPos + effectiveTooltipHeight >= viewportHeight) {
|
||||
yPos = TOOLTIP_OVERFLOW_MARGIN;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp tooltip away from the top of the chart to avoid covering annotation labels
|
||||
// (markLine/markArea labels rendered at insideEndTop are within the first ~40px)
|
||||
if (divRect) {
|
||||
yPos = Math.max(yPos, divRect.y + TOOLTIP_TOP_CLEARANCE);
|
||||
// Re-apply bottom overflow check in case top clearance pushed the tooltip off-screen
|
||||
if (yPos + effectiveTooltipHeight >= viewportHeight) {
|
||||
yPos =
|
||||
viewportHeight - effectiveTooltipHeight - TOOLTIP_OVERFLOW_MARGIN;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the position (converted back to a relative position on the canvas)
|
||||
|
||||
@@ -40,7 +40,7 @@ export function treeBuilder(
|
||||
): TreeNode[] {
|
||||
const [curGroupBy, ...restGroupby] = groupBy;
|
||||
const curData = _groupBy(data, curGroupBy);
|
||||
return transform(
|
||||
const nodes = transform(
|
||||
curData,
|
||||
(result, value, key) => {
|
||||
const name = curData[key][0][curGroupBy]!;
|
||||
@@ -59,6 +59,9 @@ export function treeBuilder(
|
||||
result.push(item);
|
||||
});
|
||||
} else {
|
||||
// Children are already null-filtered by the recursive call, so the
|
||||
// parent's value/secondaryValue exclude hidden nulls. This keeps the
|
||||
// parent arc sized to its visible children (no empty gap).
|
||||
const children = treeBuilder(
|
||||
value,
|
||||
restGroupby,
|
||||
@@ -76,12 +79,9 @@ export function treeBuilder(
|
||||
0,
|
||||
)
|
||||
: metricValue;
|
||||
const validChildren = filterNullNames
|
||||
? children.filter(child => child.name !== null)
|
||||
: children;
|
||||
result.push({
|
||||
name,
|
||||
children: validChildren,
|
||||
children,
|
||||
value: metricValue,
|
||||
secondaryValue,
|
||||
groupBy: curGroupBy,
|
||||
@@ -90,4 +90,13 @@ export function treeBuilder(
|
||||
},
|
||||
[] as TreeNode[],
|
||||
);
|
||||
// Filter at every level so single-level charts and root nodes are covered,
|
||||
// not just nested children. A parent whose children were all null-filtered
|
||||
// is dropped too: keeping it would leave a zero-value arc that yields a NaN
|
||||
// secondaryValue/value ratio for coloring and tooltips.
|
||||
return filterNullNames
|
||||
? nodes.filter(
|
||||
node => node.name !== null && node.children?.length !== 0,
|
||||
)
|
||||
: nodes;
|
||||
}
|
||||
|
||||
@@ -35,13 +35,26 @@ import {
|
||||
} from '../../src';
|
||||
import transformProps from '../../src/MixedTimeseries/transformProps';
|
||||
import {
|
||||
DEFAULT_FORM_DATA,
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps,
|
||||
} from '../../src/MixedTimeseries/types';
|
||||
import { DEFAULT_FORM_DATA } from '../../src/MixedTimeseries/types';
|
||||
import { createEchartsTimeseriesTestChartProps } from '../helpers';
|
||||
import type { SeriesOption } from 'echarts';
|
||||
|
||||
type LabelFormatterParams = {
|
||||
value: [number, number];
|
||||
dataIndex: number;
|
||||
seriesIndex: number;
|
||||
seriesName: string;
|
||||
};
|
||||
|
||||
type SeriesWithLabelFormatter = SeriesOption & {
|
||||
label?: {
|
||||
formatter?: (params: LabelFormatterParams) => string | number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a partial ChartDataResponseResult for testing.
|
||||
* Only includes the fields needed for tests, with sensible defaults for required fields.
|
||||
@@ -148,6 +161,30 @@ const queriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(defaultQueryRows, { label_map: defaultLabelMap }),
|
||||
];
|
||||
|
||||
function getSeriesWithLabelFormatter(
|
||||
series: SeriesOption[],
|
||||
name: string,
|
||||
): SeriesWithLabelFormatter {
|
||||
const result = series.find(seriesOption => seriesOption.name === name);
|
||||
expect(result).toBeDefined();
|
||||
expect((result as SeriesWithLabelFormatter).label?.formatter).toBeDefined();
|
||||
return result as SeriesWithLabelFormatter;
|
||||
}
|
||||
|
||||
function formatSeriesLabel(
|
||||
series: SeriesWithLabelFormatter,
|
||||
value: [number, number],
|
||||
) {
|
||||
const formatter = series.label?.formatter;
|
||||
expect(formatter).toBeDefined();
|
||||
return formatter?.({
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
seriesName: String(series.name),
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
test('should transform chart props for viz with showQueryIdentifiers=false', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
@@ -232,6 +269,162 @@ test('should transform chart props for viz with showQueryIdentifiers=true', () =
|
||||
]);
|
||||
});
|
||||
|
||||
test('formats value labels with the formatter for the assigned y-axis', () => {
|
||||
const timestamp = 1704067200000;
|
||||
const queryAData = createTestQueryData(
|
||||
[{ __timestamp: timestamp, lineMetric: 0.25 }],
|
||||
{
|
||||
colnames: ['__timestamp', 'lineMetric'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: { lineMetric: ['lineMetric'] },
|
||||
},
|
||||
);
|
||||
const queryBData = createTestQueryData(
|
||||
[{ __timestamp: timestamp, barMetric: 0.5 }],
|
||||
{
|
||||
colnames: ['__timestamp', 'barMetric'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: { 'barMetric (1)': ['barMetric'] },
|
||||
},
|
||||
);
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryAData, queryBData],
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: [],
|
||||
groupbyB: [],
|
||||
metrics: ['lineMetric'],
|
||||
metricsB: ['barMetric'],
|
||||
showValue: true,
|
||||
showValueB: true,
|
||||
stack: null,
|
||||
stackB: null,
|
||||
x_axis: '__timestamp',
|
||||
yAxisFormat: '.0%',
|
||||
yAxisFormatSecondary: ',.1f',
|
||||
yAxisIndex: 1,
|
||||
yAxisIndexB: 0,
|
||||
},
|
||||
queriesData: [queryAData, queryBData],
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const series = echartOptions.series as SeriesOption[];
|
||||
const lineSeries = getSeriesWithLabelFormatter(series, 'lineMetric');
|
||||
const barSeries = getSeriesWithLabelFormatter(series, 'barMetric');
|
||||
|
||||
expect(formatSeriesLabel(lineSeries, [timestamp, 0.25])).toBe('0.3');
|
||||
expect(formatSeriesLabel(barSeries, [timestamp, 0.5])).toBe('50%');
|
||||
});
|
||||
|
||||
test('formats value labels correctly when y-axis assignments are reversed', () => {
|
||||
const timestamp = 1704067200000;
|
||||
const queryAData = createTestQueryData(
|
||||
[{ __timestamp: timestamp, lineMetric: 0.25 }],
|
||||
{
|
||||
colnames: ['__timestamp', 'lineMetric'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: { lineMetric: ['lineMetric'] },
|
||||
},
|
||||
);
|
||||
const queryBData = createTestQueryData(
|
||||
[{ __timestamp: timestamp, barMetric: 0.5 }],
|
||||
{
|
||||
colnames: ['__timestamp', 'barMetric'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: { 'barMetric (1)': ['barMetric'] },
|
||||
},
|
||||
);
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryAData, queryBData],
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: [],
|
||||
groupbyB: [],
|
||||
metrics: ['lineMetric'],
|
||||
metricsB: ['barMetric'],
|
||||
showValue: true,
|
||||
showValueB: true,
|
||||
stack: null,
|
||||
stackB: null,
|
||||
x_axis: '__timestamp',
|
||||
yAxisFormat: '.0%',
|
||||
yAxisFormatSecondary: ',.1f',
|
||||
yAxisIndex: 0,
|
||||
yAxisIndexB: 1,
|
||||
},
|
||||
queriesData: [queryAData, queryBData],
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const series = echartOptions.series as SeriesOption[];
|
||||
const lineSeries = getSeriesWithLabelFormatter(series, 'lineMetric');
|
||||
const barSeries = getSeriesWithLabelFormatter(series, 'barMetric');
|
||||
|
||||
expect(formatSeriesLabel(lineSeries, [timestamp, 0.25])).toBe('25%');
|
||||
expect(formatSeriesLabel(barSeries, [timestamp, 0.5])).toBe('0.5');
|
||||
});
|
||||
|
||||
test('keeps bar value label clipping aligned with the assigned y-axis', () => {
|
||||
const timestamp = 1704067200000;
|
||||
const queryAData = createTestQueryData(
|
||||
[{ __timestamp: timestamp, lineMetric: 0.25 }],
|
||||
{
|
||||
colnames: ['__timestamp', 'lineMetric'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: { lineMetric: ['lineMetric'] },
|
||||
},
|
||||
);
|
||||
const queryBData = createTestQueryData(
|
||||
[{ __timestamp: timestamp, barMetric: 0.5 }],
|
||||
{
|
||||
colnames: ['__timestamp', 'barMetric'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: { 'barMetric (1)': ['barMetric'] },
|
||||
},
|
||||
);
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryAData, queryBData],
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: [],
|
||||
groupbyB: [],
|
||||
metrics: ['lineMetric'],
|
||||
metricsB: ['barMetric'],
|
||||
showValue: true,
|
||||
showValueB: true,
|
||||
stack: null,
|
||||
stackB: null,
|
||||
x_axis: '__timestamp',
|
||||
yAxisBounds: [undefined, 1],
|
||||
yAxisBoundsSecondary: [undefined, 0.1],
|
||||
yAxisFormat: '.0%',
|
||||
yAxisFormatSecondary: ',.1f',
|
||||
yAxisIndex: 0,
|
||||
yAxisIndexB: 1,
|
||||
},
|
||||
queriesData: [queryAData, queryBData],
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const series = echartOptions.series as SeriesOption[];
|
||||
const barSeries = getSeriesWithLabelFormatter(series, 'barMetric');
|
||||
|
||||
expect(formatSeriesLabel(barSeries, [timestamp, 0.5])).toBe('');
|
||||
});
|
||||
|
||||
describe('legend sorting', () => {
|
||||
const getChartProps = (overrides = {}) =>
|
||||
createEchartsTimeseriesTestChartProps<
|
||||
|
||||
@@ -21,6 +21,13 @@ import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { EchartsSunburstChartProps } from '../../src/Sunburst/types';
|
||||
import transformProps from '../../src/Sunburst/transformProps';
|
||||
|
||||
type SunburstSeries = {
|
||||
label?: Record<string, unknown>;
|
||||
data: { value: number }[];
|
||||
};
|
||||
const firstSeries = (echartOptions: unknown) =>
|
||||
(echartOptions as { series: SunburstSeries[] }).series[0];
|
||||
|
||||
const formData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
@@ -47,7 +54,52 @@ test('series label has no textBorderColor or textBorderWidth', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
chartProps as EchartsSunburstChartProps,
|
||||
);
|
||||
const series = (echartOptions as any).series[0];
|
||||
const series = firstSeries(echartOptions);
|
||||
expect(series.label).not.toHaveProperty('textBorderColor');
|
||||
expect(series.label).not.toHaveProperty('textBorderWidth');
|
||||
});
|
||||
|
||||
const nullValueProps = (showNullValues?: boolean) =>
|
||||
new ChartProps({
|
||||
formData: {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
columns: ['category'],
|
||||
metric: 'sum__value',
|
||||
...(showNullValues === undefined ? {} : { showNullValues }),
|
||||
},
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ category: 'A', sum__value: 10 },
|
||||
{ category: 'B', sum__value: 20 },
|
||||
{ category: null, sum__value: 5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const seriesValues = (props: ChartProps) => {
|
||||
const { echartOptions } = transformProps(props as EchartsSunburstChartProps);
|
||||
return firstSeries(echartOptions)
|
||||
.data.map(node => node.value)
|
||||
.sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
// Charts saved before the "Show Null Values" control existed have no
|
||||
// `showNullValues` in form data; they must keep showing nulls (non-breaking).
|
||||
test('keeps null values when showNullValues is unset (legacy charts)', () => {
|
||||
expect(seriesValues(nullValueProps(undefined))).toEqual([5, 10, 20]);
|
||||
});
|
||||
|
||||
test('keeps null values when showNullValues is true', () => {
|
||||
expect(seriesValues(nullValueProps(true))).toEqual([5, 10, 20]);
|
||||
});
|
||||
|
||||
// Single-column sunburst: the toggle must actually drop the null node.
|
||||
test('removes null values when showNullValues is false', () => {
|
||||
expect(seriesValues(nullValueProps(false))).toEqual([10, 20]);
|
||||
});
|
||||
|
||||
@@ -394,7 +394,7 @@ describe('test treeBuilder', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('filter null values', () => {
|
||||
test('filter null values in a nested layer (parent total excludes hidden nulls)', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
...data,
|
||||
@@ -426,6 +426,8 @@ describe('test treeBuilder', () => {
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
// The null `bar` child is removed AND its value is excluded from the
|
||||
// parent total, so the arc stays sized to its visible children (no gap).
|
||||
children: [
|
||||
{
|
||||
groupBy: 'bar',
|
||||
@@ -436,8 +438,8 @@ describe('test treeBuilder', () => {
|
||||
],
|
||||
groupBy: 'foo',
|
||||
name: 'a-2',
|
||||
secondaryValue: 4,
|
||||
value: 4,
|
||||
secondaryValue: 2,
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
children: [
|
||||
@@ -511,4 +513,137 @@ describe('test treeBuilder', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression: a single-level (single column) sunburst previously never
|
||||
// filtered, because filtering only happened in the multi-level branch.
|
||||
test('single-level: shows null nodes when filtering is off', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'a', count: 2, count2: 3 },
|
||||
{ foo: null, count: 5, count2: 7 },
|
||||
],
|
||||
['foo'],
|
||||
'count',
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{ groupBy: 'foo', name: 'a', secondaryValue: 2, value: 2 },
|
||||
{ groupBy: 'foo', name: null, secondaryValue: 5, value: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('single-level: removes null nodes when filtering is on', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'a', count: 2, count2: 3 },
|
||||
{ foo: null, count: 5, count2: 7 },
|
||||
],
|
||||
['foo'],
|
||||
'count',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{ groupBy: 'foo', name: 'a', secondaryValue: 2, value: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression: a null in the *root* (first) column previously slipped through
|
||||
// because the top-level result array was never filtered.
|
||||
test('multi-level: shows null root node when filtering is off', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'a-1', bar: 'a', count: 2, count2: 3 },
|
||||
{ foo: null, bar: 'x', count: 5, count2: 7 },
|
||||
],
|
||||
['foo', 'bar'],
|
||||
'count',
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{
|
||||
children: [{ groupBy: 'bar', name: 'a', secondaryValue: 2, value: 2 }],
|
||||
groupBy: 'foo',
|
||||
name: 'a-1',
|
||||
secondaryValue: 2,
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
children: [{ groupBy: 'bar', name: 'x', secondaryValue: 5, value: 5 }],
|
||||
groupBy: 'foo',
|
||||
name: null,
|
||||
secondaryValue: 5,
|
||||
value: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('multi-level: removes null root node (and its subtree) when filtering is on', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'a-1', bar: 'a', count: 2, count2: 3 },
|
||||
{ foo: null, bar: 'x', count: 5, count2: 7 },
|
||||
],
|
||||
['foo', 'bar'],
|
||||
'count',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{
|
||||
children: [{ groupBy: 'bar', name: 'a', secondaryValue: 2, value: 2 }],
|
||||
groupBy: 'foo',
|
||||
name: 'a-1',
|
||||
secondaryValue: 2,
|
||||
value: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// With a secondary metric, the parent's secondaryValue must also exclude the
|
||||
// hidden null child rather than leaving a stale (inflated) total.
|
||||
test('filtering excludes hidden nulls from secondary-metric totals', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'p', bar: 'a', count: 2, count2: 3 },
|
||||
{ foo: 'p', bar: null, count: 2, count2: 7 },
|
||||
],
|
||||
['foo', 'bar'],
|
||||
'count',
|
||||
'count2',
|
||||
true,
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{
|
||||
children: [{ groupBy: 'bar', name: 'a', secondaryValue: 3, value: 2 }],
|
||||
groupBy: 'foo',
|
||||
name: 'p',
|
||||
secondaryValue: 3,
|
||||
value: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// A parent whose children are all null must be dropped, not kept as a
|
||||
// zero-value arc: a retained `value: 0` node yields NaN for the
|
||||
// secondaryValue/value ratio used in linear coloring and tooltips.
|
||||
test('filtering drops parents left with no children', () => {
|
||||
const tree = treeBuilder(
|
||||
[
|
||||
{ foo: 'keep', bar: 'a', count: 2, count2: 3 },
|
||||
{ foo: 'drop', bar: null, count: 5, count2: 7 },
|
||||
],
|
||||
['foo', 'bar'],
|
||||
'count',
|
||||
'count2',
|
||||
true,
|
||||
);
|
||||
expect(tree).toEqual([
|
||||
{
|
||||
children: [{ groupBy: 'bar', name: 'a', secondaryValue: 3, value: 2 }],
|
||||
groupBy: 'foo',
|
||||
name: 'keep',
|
||||
secondaryValue: 3,
|
||||
value: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('SqlLab App', () => {
|
||||
useRedux: true,
|
||||
store: storeExceedLocalStorage,
|
||||
});
|
||||
rerender(<App updated />);
|
||||
rerender(<App />);
|
||||
expect(storeExceedLocalStorage.getActions()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: LOG_EVENT,
|
||||
@@ -118,7 +118,7 @@ describe('SqlLab App', () => {
|
||||
useRedux: true,
|
||||
store: storeExceedLocalStorage,
|
||||
});
|
||||
rerender(<App updated />);
|
||||
rerender(<App />);
|
||||
expect(storeExceedLocalStorage.getActions()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: LOG_EVENT,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import Mousetrap from 'mousetrap';
|
||||
@@ -103,59 +103,85 @@ const SqlLabStyles = styled.div`
|
||||
`};
|
||||
`;
|
||||
|
||||
type PureProps = {
|
||||
// add this for testing componentDidUpdate spec
|
||||
updated?: boolean;
|
||||
};
|
||||
type AppProps = ReturnType<typeof mergeProps>;
|
||||
|
||||
type AppProps = ReturnType<typeof mergeProps> & PureProps;
|
||||
function App({
|
||||
actions,
|
||||
localStorageUsageInKilobytes,
|
||||
queries,
|
||||
queriesLastUpdate,
|
||||
}: AppProps) {
|
||||
const [hash, setHash] = useState(window.location.hash);
|
||||
const hasLoggedLocalStorageUsageRef = useRef(false);
|
||||
|
||||
interface AppState {
|
||||
hash: string;
|
||||
}
|
||||
const showLocalStorageUsageWarning = useMemo(
|
||||
() =>
|
||||
throttle(
|
||||
(currentUsage: number, queryCount: number) => {
|
||||
actions.addDangerToast(
|
||||
t(
|
||||
"SQL Lab uses your browser's local storage to store queries and results." +
|
||||
'\nCurrently, you are using %(currentUsage)s KB out of %(maxStorage)d KB storage space.' +
|
||||
'\nTo keep SQL Lab from crashing, please delete some query tabs.' +
|
||||
'\nYou can re-access these queries by using the Save feature before you delete the tab.' +
|
||||
'\nNote that you will need to close other SQL Lab windows before you do this.',
|
||||
{
|
||||
currentUsage: currentUsage.toFixed(2),
|
||||
maxStorage: LOCALSTORAGE_MAX_USAGE_KB,
|
||||
},
|
||||
),
|
||||
);
|
||||
const eventData = {
|
||||
current_usage: currentUsage,
|
||||
query_count: queryCount,
|
||||
};
|
||||
actions.logEvent(
|
||||
LOG_ACTIONS_SQLLAB_WARN_LOCAL_STORAGE_USAGE,
|
||||
eventData,
|
||||
);
|
||||
},
|
||||
LOCALSTORAGE_WARNING_MESSAGE_THROTTLE_MS,
|
||||
{ trailing: false },
|
||||
),
|
||||
[actions],
|
||||
);
|
||||
|
||||
class App extends PureComponent<AppProps, AppState> {
|
||||
hasLoggedLocalStorageUsage: boolean;
|
||||
const onHashChanged = useCallback(() => {
|
||||
setHash(window.location.hash);
|
||||
}, []);
|
||||
|
||||
private boundOnHashChanged: () => void;
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hash: window.location.hash,
|
||||
};
|
||||
|
||||
this.boundOnHashChanged = this.onHashChanged.bind(this);
|
||||
|
||||
this.showLocalStorageUsageWarning = throttle(
|
||||
this.showLocalStorageUsageWarning,
|
||||
LOCALSTORAGE_WARNING_MESSAGE_THROTTLE_MS,
|
||||
{ trailing: false },
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('hashchange', this.boundOnHashChanged);
|
||||
// componentDidMount and componentWillUnmount
|
||||
useEffect(() => {
|
||||
window.addEventListener('hashchange', onHashChanged);
|
||||
|
||||
// Horrible hack to disable side swipe navigation when in SQL Lab. Even though the
|
||||
// docs say setting this style on any div will prevent it, turns out it only works
|
||||
// when set on the body element.
|
||||
document.body.style.overscrollBehaviorX = 'none';
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { localStorageUsageInKilobytes, actions, queries } = this.props;
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', onHashChanged);
|
||||
|
||||
// And we need to reset the overscroll behavior back to the default.
|
||||
document.body.style.overscrollBehaviorX = 'auto';
|
||||
|
||||
Mousetrap.reset();
|
||||
};
|
||||
}, [onHashChanged]);
|
||||
|
||||
// componentDidUpdate - check local storage usage
|
||||
useEffect(() => {
|
||||
const queryCount = Object.keys(queries || {}).length || 0;
|
||||
if (
|
||||
localStorageUsageInKilobytes >=
|
||||
LOCALSTORAGE_WARNING_THRESHOLD * LOCALSTORAGE_MAX_USAGE_KB
|
||||
) {
|
||||
this.showLocalStorageUsageWarning(
|
||||
localStorageUsageInKilobytes,
|
||||
queryCount,
|
||||
);
|
||||
showLocalStorageUsageWarning(localStorageUsageInKilobytes, queryCount);
|
||||
}
|
||||
if (localStorageUsageInKilobytes > 0 && !this.hasLoggedLocalStorageUsage) {
|
||||
if (
|
||||
localStorageUsageInKilobytes > 0 &&
|
||||
!hasLoggedLocalStorageUsageRef.current
|
||||
) {
|
||||
const eventData = {
|
||||
current_usage: localStorageUsageInKilobytes,
|
||||
query_count: queryCount,
|
||||
@@ -164,72 +190,38 @@ class App extends PureComponent<AppProps, AppState> {
|
||||
LOG_ACTIONS_SQLLAB_MONITOR_LOCAL_STORAGE_USAGE,
|
||||
eventData,
|
||||
);
|
||||
this.hasLoggedLocalStorageUsage = true;
|
||||
hasLoggedLocalStorageUsageRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
localStorageUsageInKilobytes,
|
||||
queries,
|
||||
actions,
|
||||
showLocalStorageUsageWarning,
|
||||
]);
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('hashchange', this.boundOnHashChanged);
|
||||
|
||||
// And now we need to reset the overscroll behavior back to the default.
|
||||
document.body.style.overscrollBehaviorX = 'auto';
|
||||
|
||||
Mousetrap.reset();
|
||||
}
|
||||
|
||||
onHashChanged() {
|
||||
this.setState({ hash: window.location.hash });
|
||||
}
|
||||
|
||||
showLocalStorageUsageWarning(currentUsage: number, queryCount: number) {
|
||||
this.props.actions.addDangerToast(
|
||||
t(
|
||||
"SQL Lab uses your browser's local storage to store queries and results." +
|
||||
'\nCurrently, you are using %(currentUsage)s KB out of %(maxStorage)d KB storage space.' +
|
||||
'\nTo keep SQL Lab from crashing, please delete some query tabs.' +
|
||||
'\nYou can re-access these queries by using the Save feature before you delete the tab.' +
|
||||
'\nNote that you will need to close other SQL Lab windows before you do this.',
|
||||
{
|
||||
currentUsage: currentUsage.toFixed(2),
|
||||
maxStorage: LOCALSTORAGE_MAX_USAGE_KB,
|
||||
},
|
||||
),
|
||||
);
|
||||
const eventData = {
|
||||
current_usage: currentUsage,
|
||||
query_count: queryCount,
|
||||
};
|
||||
this.props.actions.logEvent(
|
||||
LOG_ACTIONS_SQLLAB_WARN_LOCAL_STORAGE_USAGE,
|
||||
eventData,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { queries, queriesLastUpdate } = this.props;
|
||||
if (this.state.hash && this.state.hash === '#search') {
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: '/sqllab/history/',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (hash && hash === '#search') {
|
||||
return (
|
||||
<SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
|
||||
<QueryAutoRefresh
|
||||
queries={queries}
|
||||
queriesLastUpdate={queriesLastUpdate}
|
||||
/>
|
||||
<PopEditorTab>
|
||||
<AppLayout>
|
||||
<TabbedSqlEditors />
|
||||
</AppLayout>
|
||||
</PopEditorTab>
|
||||
</SqlLabStyles>
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: '/sqllab/history/',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
|
||||
<QueryAutoRefresh
|
||||
queries={queries}
|
||||
queriesLastUpdate={queriesLastUpdate}
|
||||
/>
|
||||
<PopEditorTab>
|
||||
<AppLayout>
|
||||
<TabbedSqlEditors />
|
||||
</AppLayout>
|
||||
</PopEditorTab>
|
||||
</SqlLabStyles>
|
||||
);
|
||||
}
|
||||
|
||||
function mapStateToProps(state: SqlLabRootState) {
|
||||
@@ -250,10 +242,8 @@ const mapDispatchToProps = {
|
||||
function mergeProps(
|
||||
stateProps: ReturnType<typeof mapStateToProps>,
|
||||
dispatchProps: typeof mapDispatchToProps,
|
||||
state: PureProps,
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
...stateProps,
|
||||
actions: dispatchProps,
|
||||
};
|
||||
|
||||
@@ -44,13 +44,13 @@ const fakeTableApiResult = {
|
||||
result: [
|
||||
{
|
||||
id: 1,
|
||||
value: 'fake api result1',
|
||||
value: 'fake_api_result1',
|
||||
label: 'fake api label1',
|
||||
type: 'table',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
value: 'fake api result2',
|
||||
value: 'fake_api_result2',
|
||||
label: 'fake api label2',
|
||||
type: 'table',
|
||||
},
|
||||
@@ -152,6 +152,64 @@ test('returns keywords including fetched function_names data', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('quotes table identifiers that require quoting in the inserted value', async () => {
|
||||
const dbFunctionNamesApiRoute = `glob:*/api/v1/database/${expectDbId}/function_names/`;
|
||||
fetchMock.get(dbFunctionNamesApiRoute, fakeFunctionNamesApiResult);
|
||||
|
||||
act(() => {
|
||||
store.dispatch(
|
||||
tableApiUtil.upsertQueryData(
|
||||
'tables',
|
||||
{ dbId: expectDbId, schema: expectSchema },
|
||||
{
|
||||
options: [
|
||||
{ value: 'COVID Vaccines', label: 'COVID Vaccines', type: 'table' },
|
||||
{ value: 'simple_table', label: 'simple_table', type: 'table' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useKeywords({
|
||||
queryEditorId: 'testqueryid',
|
||||
dbId: expectDbId,
|
||||
schema: expectSchema,
|
||||
}),
|
||||
{
|
||||
wrapper: createWrapper({
|
||||
useRedux: true,
|
||||
store,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.callHistory.calls(dbFunctionNamesApiRoute).length).toBe(1),
|
||||
);
|
||||
|
||||
// A name that needs quoting is inserted as a double-quoted identifier,
|
||||
// while its display name stays human-readable.
|
||||
expect(result.current).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'COVID Vaccines',
|
||||
value: '"COVID Vaccines"',
|
||||
meta: 'table',
|
||||
}),
|
||||
);
|
||||
// A simple identifier is inserted as-is, without quotes.
|
||||
expect(result.current).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'simple_table',
|
||||
value: 'simple_table',
|
||||
meta: 'table',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('skip fetching if autocomplete skipped', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
|
||||
@@ -53,6 +53,14 @@ const getHelperText = (value: string) =>
|
||||
detail: value,
|
||||
};
|
||||
|
||||
// Names that aren't simple identifiers (spaces, punctuation, leading digits)
|
||||
// must be double-quoted to be valid SQL, with embedded quotes doubled.
|
||||
const SIMPLE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const quoteIdentifier = (identifier: string) =>
|
||||
SIMPLE_IDENTIFIER_RE.test(identifier)
|
||||
? identifier
|
||||
: `"${identifier.replace(/"/g, '""')}"`;
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
export function useKeywords(
|
||||
@@ -197,7 +205,7 @@ export function useKeywords(
|
||||
() =>
|
||||
allCachedTables.map(({ value, label, schema: tableSchema }) => ({
|
||||
name: label,
|
||||
value,
|
||||
value: quoteIdentifier(value),
|
||||
schema: tableSchema,
|
||||
score: TABLE_AUTOCOMPLETE_SCORE,
|
||||
meta: 'table',
|
||||
|
||||
@@ -891,7 +891,7 @@ const SqlEditor: FC<Props> = ({
|
||||
callback(currentSQL.current);
|
||||
};
|
||||
const renderCopyQueryButton = () => (
|
||||
<Button type="primary">{t('COPY QUERY')}</Button>
|
||||
<Button type="primary">{t('Copy query')}</Button>
|
||||
);
|
||||
|
||||
const renderDatasetWarning = () => (
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { EditableTabs } from '@superset-ui/core/components/Tabs';
|
||||
import { connect } from 'react-redux';
|
||||
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { styled, css } from '@apache-superset/core/theme';
|
||||
import { Logger } from 'src/logger/LogUtils';
|
||||
import { EmptyState, Tooltip } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
@@ -33,10 +33,10 @@ import SqlEditor from '../SqlEditor';
|
||||
import SqlEditorTabHeader from '../SqlEditorTabHeader';
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
queryEditors: [],
|
||||
queryEditors: [] as QueryEditor[],
|
||||
offline: false,
|
||||
saveQueryWarning: null,
|
||||
scheduleQueryWarning: null,
|
||||
saveQueryWarning: null as string | null,
|
||||
scheduleQueryWarning: null as string | null,
|
||||
};
|
||||
|
||||
const StyledEditableTabs = styled(EditableTabs)`
|
||||
@@ -90,170 +90,201 @@ const TabTitle = styled.span`
|
||||
text-transform: none;
|
||||
`;
|
||||
|
||||
const AddTabIconWrapper = styled.span`
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
`;
|
||||
|
||||
// Get the user's OS
|
||||
const userOS = detectOS();
|
||||
|
||||
type TabbedSqlEditorsProps = ReturnType<typeof mergeProps>;
|
||||
|
||||
class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
|
||||
constructor(props: TabbedSqlEditorsProps) {
|
||||
super(props);
|
||||
this.removeQueryEditor = this.removeQueryEditor.bind(this);
|
||||
this.handleSelect = this.handleSelect.bind(this);
|
||||
this.handleEdit = this.handleEdit.bind(this);
|
||||
}
|
||||
function TabbedSqlEditors({
|
||||
actions,
|
||||
queryEditors = DEFAULT_PROPS.queryEditors,
|
||||
queries,
|
||||
tabHistory,
|
||||
displayLimit,
|
||||
offline = DEFAULT_PROPS.offline,
|
||||
defaultQueryLimit,
|
||||
maxRow,
|
||||
saveQueryWarning = DEFAULT_PROPS.saveQueryWarning,
|
||||
scheduleQueryWarning = DEFAULT_PROPS.scheduleQueryWarning,
|
||||
}: TabbedSqlEditorsProps) {
|
||||
const activeQueryEditor = useMemo(() => {
|
||||
if (tabHistory.length === 0) {
|
||||
return queryEditors[0];
|
||||
}
|
||||
const qeid = tabHistory[tabHistory.length - 1];
|
||||
return queryEditors.find(qe => qe.id === qeid) || null;
|
||||
}, [tabHistory, queryEditors]);
|
||||
|
||||
componentDidMount() {
|
||||
const qe = this.activeQueryEditor();
|
||||
const latestQuery = this.props.queries[qe?.latestQueryId || ''];
|
||||
// Track the last persisted resultsKey we fetched, so the effect retries when
|
||||
// the active query editor resolves after mount (or its latest query changes)
|
||||
// but dedupes when the same resultsKey has already been fetched.
|
||||
const fetchedResultsKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Fetch query results for the active editor's latest query when its
|
||||
// persisted resultsKey changes (equivalent to componentDidMount, but resilient
|
||||
// to async hydration of activeQueryEditor).
|
||||
useEffect(() => {
|
||||
const latestQuery = queries[activeQueryEditor?.latestQueryId || ''];
|
||||
const resultsKey = latestQuery?.resultsKey;
|
||||
if (
|
||||
isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) &&
|
||||
latestQuery?.resultsKey
|
||||
resultsKey &&
|
||||
fetchedResultsKeyRef.current !== resultsKey
|
||||
) {
|
||||
fetchedResultsKeyRef.current = resultsKey;
|
||||
// when results are not stored in localStorage they need to be
|
||||
// fetched from the results backend (if configured)
|
||||
this.props.actions.fetchQueryResults(
|
||||
latestQuery,
|
||||
this.props.displayLimit,
|
||||
);
|
||||
actions.fetchQueryResults(latestQuery, displayLimit);
|
||||
}
|
||||
}
|
||||
}, [queries, activeQueryEditor, actions, displayLimit]);
|
||||
|
||||
activeQueryEditor() {
|
||||
if (this.props.tabHistory.length === 0) {
|
||||
return this.props.queryEditors[0];
|
||||
}
|
||||
const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
|
||||
return this.props.queryEditors.find(qe => qe.id === qeid) || null;
|
||||
}
|
||||
const newQueryEditor = useCallback(() => {
|
||||
actions.addNewQueryEditor();
|
||||
}, [actions]);
|
||||
|
||||
newQueryEditor() {
|
||||
this.props.actions.addNewQueryEditor();
|
||||
}
|
||||
const removeQueryEditor = useCallback(
|
||||
(qe: QueryEditor) => {
|
||||
actions.removeQueryEditor(qe);
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
|
||||
handleSelect(key: string) {
|
||||
const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
|
||||
if (key !== qeid) {
|
||||
const queryEditor = this.props.queryEditors.find(qe => qe.id === key);
|
||||
if (!queryEditor) {
|
||||
return;
|
||||
const handleSelect = useCallback(
|
||||
(key: string) => {
|
||||
const qeid = tabHistory[tabHistory.length - 1];
|
||||
if (key !== qeid) {
|
||||
const queryEditor = queryEditors.find(qe => qe.id === key);
|
||||
if (!queryEditor) {
|
||||
return;
|
||||
}
|
||||
actions.setActiveQueryEditor(queryEditor);
|
||||
}
|
||||
this.props.actions.setActiveQueryEditor(queryEditor);
|
||||
}
|
||||
}
|
||||
},
|
||||
[tabHistory, queryEditors, actions],
|
||||
);
|
||||
|
||||
handleEdit(key: string, action: string) {
|
||||
if (action === 'remove') {
|
||||
const qe = this.props.queryEditors.find(qe => qe.id === key);
|
||||
if (qe) {
|
||||
this.removeQueryEditor(qe);
|
||||
const handleEdit = useCallback(
|
||||
(key: string, action: string) => {
|
||||
if (action === 'remove') {
|
||||
const qe = queryEditors.find(qe => qe.id === key);
|
||||
if (qe) {
|
||||
removeQueryEditor(qe);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action === 'add') {
|
||||
Logger.markTimeOrigin();
|
||||
this.newQueryEditor();
|
||||
}
|
||||
}
|
||||
if (action === 'add') {
|
||||
Logger.markTimeOrigin();
|
||||
newQueryEditor();
|
||||
}
|
||||
},
|
||||
[queryEditors, removeQueryEditor, newQueryEditor],
|
||||
);
|
||||
|
||||
removeQueryEditor(qe: QueryEditor) {
|
||||
this.props.actions.removeQueryEditor(qe);
|
||||
}
|
||||
|
||||
onTabClicked = () => {
|
||||
const onTabClicked = useCallback(() => {
|
||||
Logger.markTimeOrigin();
|
||||
const noQueryEditors = this.props.queryEditors?.length === 0;
|
||||
const noQueryEditors = queryEditors?.length === 0;
|
||||
if (noQueryEditors) {
|
||||
this.newQueryEditor();
|
||||
newQueryEditor();
|
||||
}
|
||||
}, [queryEditors, newQueryEditor]);
|
||||
|
||||
const editors = useMemo(
|
||||
() =>
|
||||
queryEditors?.map(qe => ({
|
||||
key: qe.id,
|
||||
label: <SqlEditorTabHeader queryEditor={qe} />,
|
||||
children: (
|
||||
<ErrorBoundary>
|
||||
<SqlEditor
|
||||
queryEditor={qe}
|
||||
defaultQueryLimit={defaultQueryLimit}
|
||||
maxRow={maxRow}
|
||||
displayLimit={displayLimit}
|
||||
saveQueryWarning={saveQueryWarning}
|
||||
scheduleQueryWarning={scheduleQueryWarning}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
})),
|
||||
[
|
||||
queryEditors,
|
||||
defaultQueryLimit,
|
||||
maxRow,
|
||||
displayLimit,
|
||||
saveQueryWarning,
|
||||
scheduleQueryWarning,
|
||||
],
|
||||
);
|
||||
|
||||
const emptyTab = (
|
||||
<StyledTab>
|
||||
<TabTitle>{t('Add a new tab')}</TabTitle>
|
||||
<Tooltip
|
||||
id="add-tab"
|
||||
placement="bottom"
|
||||
title={
|
||||
userOS === 'Windows'
|
||||
? t('New tab (Ctrl + q)')
|
||||
: t('New tab (Ctrl + t)')
|
||||
}
|
||||
>
|
||||
<Icons.PlusCircleOutlined
|
||||
iconSize="s"
|
||||
css={css`
|
||||
vertical-align: middle;
|
||||
`}
|
||||
data-test="add-tab-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</StyledTab>
|
||||
);
|
||||
|
||||
const emptyTabState = {
|
||||
key: '0',
|
||||
label: emptyTab,
|
||||
children: (
|
||||
<EmptyState
|
||||
image="empty_sql_chart.svg"
|
||||
size="large"
|
||||
description={t('Add a new tab to create SQL Query')}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
render() {
|
||||
const editors = this.props.queryEditors?.map(qe => ({
|
||||
key: qe.id,
|
||||
label: <SqlEditorTabHeader queryEditor={qe} />,
|
||||
children: (
|
||||
<ErrorBoundary>
|
||||
<SqlEditor
|
||||
queryEditor={qe}
|
||||
defaultQueryLimit={this.props.defaultQueryLimit}
|
||||
maxRow={this.props.maxRow}
|
||||
displayLimit={this.props.displayLimit}
|
||||
saveQueryWarning={this.props.saveQueryWarning}
|
||||
scheduleQueryWarning={this.props.scheduleQueryWarning}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
}));
|
||||
const tabItems = queryEditors?.length > 0 ? editors : [emptyTabState];
|
||||
|
||||
const emptyTab = (
|
||||
<StyledTab>
|
||||
<TabTitle>{t('Add a new tab')}</TabTitle>
|
||||
return (
|
||||
<StyledEditableTabs
|
||||
activeKey={tabHistory[tabHistory.length - 1]}
|
||||
id="a11y-query-editor-tabs"
|
||||
className="SqlEditorTabs"
|
||||
data-test="sql-editor-tabs"
|
||||
onChange={handleSelect}
|
||||
hideAdd={offline}
|
||||
onTabClick={onTabClicked}
|
||||
onEdit={handleEdit}
|
||||
type={queryEditors?.length === 0 ? 'card' : 'editable-card'}
|
||||
addIcon={
|
||||
<Tooltip
|
||||
id="add-tab"
|
||||
placement="bottom"
|
||||
placement="left"
|
||||
title={
|
||||
userOS === 'Windows'
|
||||
? t('New tab (Ctrl + q)')
|
||||
: t('New tab (Ctrl + t)')
|
||||
}
|
||||
>
|
||||
<AddTabIconWrapper>
|
||||
<Icons.PlusCircleOutlined iconSize="s" data-test="add-tab-icon" />
|
||||
</AddTabIconWrapper>
|
||||
<Icons.PlusOutlined
|
||||
iconSize="l"
|
||||
css={css`
|
||||
vertical-align: middle;
|
||||
`}
|
||||
data-test="add-tab-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</StyledTab>
|
||||
);
|
||||
|
||||
const emptyTabState = {
|
||||
key: '0',
|
||||
label: emptyTab,
|
||||
children: (
|
||||
<EmptyState
|
||||
image="empty_sql_chart.svg"
|
||||
size="large"
|
||||
description={t('Add a new tab to create SQL Query')}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const tabItems =
|
||||
this.props.queryEditors?.length > 0 ? editors : [emptyTabState];
|
||||
|
||||
return (
|
||||
<StyledEditableTabs
|
||||
activeKey={this.props.tabHistory[this.props.tabHistory.length - 1]}
|
||||
id="a11y-query-editor-tabs"
|
||||
className="SqlEditorTabs"
|
||||
data-test="sql-editor-tabs"
|
||||
onChange={this.handleSelect}
|
||||
hideAdd={this.props.offline}
|
||||
onTabClick={this.onTabClicked}
|
||||
onEdit={this.handleEdit}
|
||||
type={this.props.queryEditors?.length === 0 ? 'card' : 'editable-card'}
|
||||
addIcon={
|
||||
<Tooltip
|
||||
id="add-tab"
|
||||
placement="left"
|
||||
title={
|
||||
userOS === 'Windows'
|
||||
? t('New tab (Ctrl + q)')
|
||||
: t('New tab (Ctrl + t)')
|
||||
}
|
||||
>
|
||||
<AddTabIconWrapper>
|
||||
<Icons.PlusOutlined iconSize="l" data-test="add-tab-icon" />
|
||||
</AddTabIconWrapper>
|
||||
</Tooltip>
|
||||
}
|
||||
items={tabItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
items={tabItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function mapStateToProps({ sqlLab, common }: SqlLabRootState) {
|
||||
|
||||
@@ -221,12 +221,6 @@ test('should render the error', async () => {
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockRejectedValue(new Error('Something went wrong'));
|
||||
await waitForRender();
|
||||
// The error is wrapped in an Alert component with a stable headline and the
|
||||
// raw error text in the description — no more bare ``<pre>`` elements.
|
||||
expect(await screen.findByRole('alert')).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText('Failed to load drill-to-detail rows'),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ import BooleanCell from '@superset-ui/core/components/Table/cell-renderers/Boole
|
||||
import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
|
||||
import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
||||
import Table, {
|
||||
ColumnsType,
|
||||
@@ -363,18 +362,13 @@ export default function DrillDetailPane({
|
||||
if (responseError) {
|
||||
// Render error if page download failed
|
||||
tableContent = (
|
||||
<div
|
||||
<pre
|
||||
css={css`
|
||||
margin-top: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load drill-to-detail rows')}
|
||||
description={responseError}
|
||||
/>
|
||||
</div>
|
||||
{responseError}
|
||||
</pre>
|
||||
);
|
||||
} else if (bootstrapping) {
|
||||
// Render loading if first page hasn't loaded
|
||||
|
||||
@@ -49,7 +49,6 @@ const DISABLED_REASONS = {
|
||||
DATABASE: t(
|
||||
'Drill to detail is disabled for this database. Change the database settings to enable it.',
|
||||
),
|
||||
DATASOURCE: t('Drill to detail is not available for this datasource type.'),
|
||||
NO_AGGREGATIONS: t(
|
||||
'Drill to detail is disabled because this chart does not group data by dimension value.',
|
||||
),
|
||||
@@ -116,17 +115,6 @@ export const useDrillDetailMenuItems = ({
|
||||
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||
);
|
||||
|
||||
// Capability flag on the datasource itself. Datasources that don't model
|
||||
// raw rows (e.g. semantic views) opt out via ``supports_drill_to_detail``
|
||||
// in the explore data payload.
|
||||
const datasourceSupportsDrillToDetail = useSelector<
|
||||
RootState,
|
||||
boolean | undefined
|
||||
>(
|
||||
({ datasources }) =>
|
||||
datasources[formData.datasource]?.supports_drill_to_detail,
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(filters: BinaryQueryObjectFilterClause[], event: MouseEvent) => {
|
||||
onClick(event);
|
||||
@@ -169,10 +157,7 @@ export const useDrillDetailMenuItems = ({
|
||||
|
||||
let drillDisabled;
|
||||
let drillByDisabled;
|
||||
if (datasourceSupportsDrillToDetail === false) {
|
||||
drillDisabled = DISABLED_REASONS.DATASOURCE;
|
||||
drillByDisabled = DISABLED_REASONS.DATASOURCE;
|
||||
} else if (drillToDetailDisabled) {
|
||||
if (drillToDetailDisabled) {
|
||||
drillDisabled = DISABLED_REASONS.DATABASE;
|
||||
drillByDisabled = DISABLED_REASONS.DATABASE;
|
||||
} else if (handlesDimensionContextMenu) {
|
||||
|
||||
@@ -426,45 +426,3 @@ test.skip('context menu for supported chart, dimensions, all filters', async ()
|
||||
await setupMenu(filters);
|
||||
await expectDrillToDetailByAll(filters);
|
||||
});
|
||||
|
||||
const buildStateWithUnsupportedDatasource = () => {
|
||||
const baseState = getMockStoreWithNativeFilters().getState();
|
||||
const datasourceKey = defaultFormData.datasource as string;
|
||||
return {
|
||||
...baseState,
|
||||
datasources: {
|
||||
...baseState.datasources,
|
||||
[datasourceKey]: {
|
||||
...baseState.datasources[datasourceKey],
|
||||
supports_drill_to_detail: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test('dropdown menu when datasource opts out via supports_drill_to_detail=false', async () => {
|
||||
cleanup();
|
||||
render(<MockRenderChart formData={defaultFormData} />, {
|
||||
useRouter: true,
|
||||
useRedux: true,
|
||||
initialState: buildStateWithUnsupportedDatasource(),
|
||||
});
|
||||
|
||||
await expectDrillToDetailDisabled(
|
||||
'Drill to detail is not available for this datasource type.',
|
||||
);
|
||||
await expectNoDrillToDetailBy();
|
||||
});
|
||||
|
||||
test('context menu when datasource opts out via supports_drill_to_detail=false', async () => {
|
||||
cleanup();
|
||||
render(<MockRenderChart formData={defaultFormData} isContextMenu />, {
|
||||
useRouter: true,
|
||||
useRedux: true,
|
||||
initialState: buildStateWithUnsupportedDatasource(),
|
||||
});
|
||||
|
||||
const message = 'Drill to detail is not available for this datasource type.';
|
||||
await expectDrillToDetailDisabled(message);
|
||||
await expectDrillToDetailByDisabled(message);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
@@ -37,6 +38,34 @@ test('renders with custom copy node', () => {
|
||||
expect(screen.getByRole('link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Regression guard: passing a non-element copyNode (string or number) used to
|
||||
// crash because cloneElement only accepts React elements. The render path now
|
||||
// gates the cloneElement call behind isValidElement and falls back to a span
|
||||
// wrapper, so plain primitives should render without throwing.
|
||||
test('renders with string copyNode without crashing', () => {
|
||||
render(<CopyToClipboard copyNode="just text" />, { useRedux: true });
|
||||
expect(screen.getByRole('button')).toHaveTextContent('just text');
|
||||
});
|
||||
|
||||
test('renders with number copyNode without crashing', () => {
|
||||
render(<CopyToClipboard copyNode={42} />, { useRedux: true });
|
||||
expect(screen.getByRole('button')).toHaveTextContent('42');
|
||||
});
|
||||
|
||||
test('non-element copyNode wrapper is keyboard-activatable', async () => {
|
||||
const onCopyEnd = jest.fn();
|
||||
render(<CopyToClipboard copyNode="copy me" onCopyEnd={onCopyEnd} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('tabIndex', '0');
|
||||
button.focus();
|
||||
// user-event v12 (pinned in this repo) doesn't expose .keyboard(); use
|
||||
// fireEvent to dispatch the Enter keydown directly to the focused button.
|
||||
fireEvent.keyDown(button, { key: 'Enter' });
|
||||
await waitFor(() => expect(onCopyEnd).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test('renders without text showing', () => {
|
||||
const text = 'Text';
|
||||
render(<CopyToClipboard text={text} shouldShowText={false} />, {
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Component, cloneElement, ReactElement } from 'react';
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
type KeyboardEvent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, SupersetTheme } from '@apache-superset/core/theme';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
@@ -24,121 +30,142 @@ import { Tooltip } from '@superset-ui/core/components';
|
||||
import withToasts from '../MessageToasts/withToasts';
|
||||
import type { CopyToClipboardProps } from './types';
|
||||
|
||||
const defaultProps: Partial<CopyToClipboardProps> = {
|
||||
copyNode: <span>{t('Copy')}</span>,
|
||||
onCopyEnd: () => {},
|
||||
shouldShowText: true,
|
||||
wrapped: true,
|
||||
tooltipText: t('Copy to clipboard'),
|
||||
hideTooltip: false,
|
||||
};
|
||||
function CopyToClip({
|
||||
copyNode = <span>{t('Copy')}</span>,
|
||||
onCopyEnd = () => {},
|
||||
shouldShowText = true,
|
||||
wrapped = true,
|
||||
tooltipText = t('Copy to clipboard'),
|
||||
hideTooltip = false,
|
||||
disabled,
|
||||
getText,
|
||||
text,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
}: CopyToClipboardProps) {
|
||||
const copyToClipboard = useCallback(
|
||||
(textToCopy: Promise<string>) => {
|
||||
copyTextToClipboard(() => textToCopy)
|
||||
.then(() => {
|
||||
addSuccessToast(t('Copied to clipboard!'));
|
||||
})
|
||||
.catch(() => {
|
||||
addDangerToast(
|
||||
t(
|
||||
'Sorry, your browser does not support copying. Use Ctrl / Cmd + C!',
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (onCopyEnd) onCopyEnd();
|
||||
});
|
||||
},
|
||||
[addSuccessToast, addDangerToast, onCopyEnd],
|
||||
);
|
||||
|
||||
class CopyToClip extends Component<CopyToClipboardProps> {
|
||||
static defaultProps = defaultProps;
|
||||
|
||||
constructor(props: CopyToClipboardProps) {
|
||||
super(props);
|
||||
this.copyToClipboard = this.copyToClipboard.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.props.disabled) {
|
||||
const onClick = useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (this.props.getText) {
|
||||
this.props.getText((d: string) => {
|
||||
this.copyToClipboard(Promise.resolve(d));
|
||||
if (getText) {
|
||||
getText((d: string) => {
|
||||
copyToClipboard(Promise.resolve(d));
|
||||
});
|
||||
} else {
|
||||
this.copyToClipboard(Promise.resolve(this.props.text || ''));
|
||||
copyToClipboard(Promise.resolve(text || ''));
|
||||
}
|
||||
}
|
||||
}, [disabled, getText, text, copyToClipboard]);
|
||||
|
||||
getDecoratedCopyNode() {
|
||||
const copyNode = this.props.copyNode as ReactElement;
|
||||
const { disabled } = this.props;
|
||||
return cloneElement(copyNode, {
|
||||
style: {
|
||||
...copyNode.props.style,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
},
|
||||
onClick: disabled ? undefined : this.onClick,
|
||||
'aria-disabled': disabled || undefined,
|
||||
tabIndex: disabled ? -1 : copyNode.props.tabIndex,
|
||||
});
|
||||
}
|
||||
|
||||
copyToClipboard(textToCopy: Promise<string>) {
|
||||
copyTextToClipboard(() => textToCopy)
|
||||
.then(() => {
|
||||
this.props.addSuccessToast(t('Copied to clipboard!'));
|
||||
})
|
||||
.catch(() => {
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'Sorry, your browser does not support copying. Use Ctrl / Cmd + C!',
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.props.onCopyEnd) this.props.onCopyEnd();
|
||||
const getDecoratedCopyNode = useCallback(() => {
|
||||
const cursor = disabled ? 'not-allowed' : 'pointer';
|
||||
if (isValidElement(copyNode)) {
|
||||
const node = copyNode as ReactElement;
|
||||
return cloneElement(node, {
|
||||
style: {
|
||||
...node.props.style,
|
||||
cursor,
|
||||
},
|
||||
onClick: disabled ? undefined : onClick,
|
||||
'aria-disabled': disabled || undefined,
|
||||
tabIndex: disabled ? -1 : node.props.tabIndex,
|
||||
});
|
||||
}
|
||||
|
||||
renderTooltip(cursor: string) {
|
||||
}
|
||||
const handleKeyDown = disabled
|
||||
? undefined
|
||||
: (event: KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
// Prevent space-scroll when the wrapper is focused.
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<span
|
||||
style={{ cursor }}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
aria-disabled={disabled || undefined}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{copyNode}
|
||||
</span>
|
||||
);
|
||||
}, [copyNode, disabled, onClick]);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(cursor: string) => (
|
||||
<>
|
||||
{!this.props.hideTooltip ? (
|
||||
{!hideTooltip ? (
|
||||
<Tooltip
|
||||
id="copy-to-clipboard-tooltip"
|
||||
placement="topRight"
|
||||
style={{ cursor }}
|
||||
title={this.props.tooltipText || ''}
|
||||
title={tooltipText || ''}
|
||||
trigger={['hover']}
|
||||
arrow={{ pointAtCenter: true }}
|
||||
>
|
||||
{/* Wrap in a span so antd Tooltip has a real DOM ref target;
|
||||
avoids findDOMNode fallback when copyNode is a function
|
||||
component without forwardRef. */}
|
||||
<span>{this.getDecoratedCopyNode()}</span>
|
||||
<span>{getDecoratedCopyNode()}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
this.getDecoratedCopyNode()
|
||||
getDecoratedCopyNode()
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
),
|
||||
[hideTooltip, tooltipText, getDecoratedCopyNode],
|
||||
);
|
||||
|
||||
renderNotWrapped() {
|
||||
return this.renderTooltip(this.props.disabled ? 'not-allowed' : 'pointer');
|
||||
}
|
||||
const renderNotWrapped = useCallback(
|
||||
() => renderTooltip(disabled ? 'not-allowed' : 'pointer'),
|
||||
[renderTooltip, disabled],
|
||||
);
|
||||
|
||||
renderLink() {
|
||||
return (
|
||||
const renderLink = useCallback(
|
||||
() => (
|
||||
<span css={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
{this.props.shouldShowText && this.props.text && (
|
||||
{shouldShowText && text && (
|
||||
<span
|
||||
data-test="short-url"
|
||||
css={(theme: SupersetTheme) => css`
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
`}
|
||||
>
|
||||
{this.props.text}
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
{this.renderTooltip(this.props.disabled ? 'not-allowed' : 'pointer')}
|
||||
{renderTooltip(disabled ? 'not-allowed' : 'pointer')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
),
|
||||
[shouldShowText, text, renderTooltip, disabled],
|
||||
);
|
||||
|
||||
render() {
|
||||
const { wrapped } = this.props;
|
||||
if (!wrapped) {
|
||||
return this.renderNotWrapped();
|
||||
}
|
||||
return this.renderLink();
|
||||
if (!wrapped) {
|
||||
return renderNotWrapped();
|
||||
}
|
||||
return renderLink();
|
||||
}
|
||||
|
||||
export const CopyToClipboard = withToasts(CopyToClip);
|
||||
|
||||
@@ -32,5 +32,5 @@ test('renders a table', () => {
|
||||
const tableBody = container.querySelector('.ant-table-tbody');
|
||||
expect(tableBody).toBeInTheDocument();
|
||||
const rows = tableBody?.getElementsByTagName('tr');
|
||||
expect(rows).toHaveLength(mockDatasource['7__table'].columns.length + 1);
|
||||
expect(rows).toHaveLength(mockDatasource['7__table'].columns.length);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent, ReactNode } from 'react';
|
||||
import {
|
||||
ReactNode,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, css, SupersetTheme } from '@apache-superset/core/theme';
|
||||
@@ -33,8 +40,8 @@ import Fieldset from '../Fieldset';
|
||||
import { recurseReactClone } from '../../utils';
|
||||
import {
|
||||
type CRUDCollectionProps,
|
||||
type CRUDCollectionState,
|
||||
type Sort,
|
||||
SortOrder as SortOrderEnum,
|
||||
} from '../../types';
|
||||
|
||||
const CrudButtonWrapper = styled.div`
|
||||
@@ -52,18 +59,18 @@ const StyledButtonWrapper = styled.span`
|
||||
`}
|
||||
`;
|
||||
|
||||
type CollectionItem = { id: string | number; [key: string]: any };
|
||||
type CollectionItem = { id: string | number; [key: string]: unknown };
|
||||
|
||||
function createKeyedCollection(arr: Array<object>) {
|
||||
const collectionArray = arr.map(
|
||||
(o: any) =>
|
||||
(o: Record<string, unknown>) =>
|
||||
({
|
||||
...o,
|
||||
id: o.id || nanoid(),
|
||||
id: o.id != null ? o.id : nanoid(),
|
||||
}) as CollectionItem,
|
||||
);
|
||||
|
||||
const collection: Record<PropertyKey, any> = {};
|
||||
const collection: Record<PropertyKey, CollectionItem> = {};
|
||||
collectionArray.forEach((o: CollectionItem) => {
|
||||
collection[o.id] = o;
|
||||
});
|
||||
@@ -74,270 +81,317 @@ function createKeyedCollection(arr: Array<object>) {
|
||||
};
|
||||
}
|
||||
|
||||
export default class CRUDCollection extends PureComponent<
|
||||
CRUDCollectionProps,
|
||||
CRUDCollectionState
|
||||
> {
|
||||
constructor(props: CRUDCollectionProps) {
|
||||
super(props);
|
||||
|
||||
const { collection, collectionArray } = createKeyedCollection(
|
||||
props.collection,
|
||||
);
|
||||
|
||||
// Get initial page size from pagination prop
|
||||
const initialPageSize =
|
||||
typeof props.pagination === 'object' && props.pagination?.pageSize
|
||||
? props.pagination.pageSize
|
||||
: 10;
|
||||
|
||||
this.state = {
|
||||
expandedColumns: {},
|
||||
collection,
|
||||
collectionArray,
|
||||
sortColumn: '',
|
||||
sort: 0,
|
||||
currentPage: 1,
|
||||
pageSize: initialPageSize,
|
||||
};
|
||||
this.onAddItem = this.onAddItem.bind(this);
|
||||
this.renderExpandableSection = this.renderExpandableSection.bind(this);
|
||||
this.getLabel = this.getLabel.bind(this);
|
||||
this.onFieldsetChange = this.onFieldsetChange.bind(this);
|
||||
this.changeCollection = this.changeCollection.bind(this);
|
||||
this.handleTableChange = this.handleTableChange.bind(this);
|
||||
this.buildTableColumns = this.buildTableColumns.bind(this);
|
||||
this.toggleExpand = this.toggleExpand.bind(this);
|
||||
export default function CRUDCollection({
|
||||
allowAddItem = false,
|
||||
allowDeletes = false,
|
||||
collection: propsCollection,
|
||||
columnLabels,
|
||||
columnLabelTooltips,
|
||||
emptyMessage = t('No items'),
|
||||
expandFieldset,
|
||||
itemGenerator,
|
||||
itemCellProps,
|
||||
itemRenderers,
|
||||
onChange,
|
||||
tableColumns,
|
||||
sortColumns = [],
|
||||
stickyHeader = false,
|
||||
pagination = false,
|
||||
filterTerm,
|
||||
filterFields,
|
||||
}: CRUDCollectionProps) {
|
||||
const [expandedColumns, setExpandedColumns] = useState<
|
||||
Record<PropertyKey, boolean>
|
||||
>({});
|
||||
// Seed both pieces of state from a single createKeyedCollection() pass so
|
||||
// that items lacking an `id` get one consistent set of synthetic ids
|
||||
// (matching the prior class component, which keyed the collection once).
|
||||
const initialKeyed = useRef<ReturnType<typeof createKeyedCollection>>();
|
||||
if (!initialKeyed.current) {
|
||||
initialKeyed.current = createKeyedCollection(propsCollection);
|
||||
}
|
||||
const [collection, setCollection] = useState<
|
||||
Record<PropertyKey, CollectionItem>
|
||||
>(() => initialKeyed.current!.collection);
|
||||
const [collectionArray, setCollectionArray] = useState<CollectionItem[]>(
|
||||
() => initialKeyed.current!.collectionArray,
|
||||
);
|
||||
const [sortColumn, setSortColumn] = useState<string>('');
|
||||
const [sort, setSort] = useState<SortOrderEnum>(SortOrderEnum.Unsorted);
|
||||
// Controlled pagination: tracked so that filtering can clamp currentPage
|
||||
// back to a valid page (avoids the user being stranded on an empty page
|
||||
// when filterTerm shrinks the result set).
|
||||
const [pageSize, setPageSize] = useState<number>(() =>
|
||||
typeof pagination === 'object' && pagination?.pageSize
|
||||
? pagination.pageSize
|
||||
: 10,
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
|
||||
componentDidUpdate(prevProps: CRUDCollectionProps) {
|
||||
if (this.props.collection !== prevProps.collection) {
|
||||
const { collection, collectionArray } = createKeyedCollection(
|
||||
this.props.collection,
|
||||
);
|
||||
// Sync with props.collection changes
|
||||
useEffect(() => {
|
||||
const { collection: newCollection, collectionArray: newCollectionArray } =
|
||||
createKeyedCollection(propsCollection);
|
||||
setCollection(newCollection);
|
||||
setCollectionArray(newCollectionArray);
|
||||
}, [propsCollection]);
|
||||
|
||||
this.setState(prevState => ({
|
||||
collection,
|
||||
collectionArray,
|
||||
expandedColumns: prevState.expandedColumns,
|
||||
}));
|
||||
}
|
||||
}
|
||||
const onCellChange = useCallback(
|
||||
(id: string | number, col: string, val: unknown) => {
|
||||
setCollection(prevCollection => {
|
||||
const updatedCollection = {
|
||||
...prevCollection,
|
||||
[id]: {
|
||||
...prevCollection[id],
|
||||
[col]: val,
|
||||
},
|
||||
};
|
||||
return updatedCollection;
|
||||
});
|
||||
|
||||
onCellChange(id: string | number, col: string, val: unknown) {
|
||||
this.setState(prevState => {
|
||||
const updatedCollection = {
|
||||
...prevState.collection,
|
||||
[id]: {
|
||||
...prevState.collection[id],
|
||||
[col]: val,
|
||||
},
|
||||
};
|
||||
const updatedCollectionArray = prevState.collectionArray.map(item =>
|
||||
item.id === id ? updatedCollection[id] : item,
|
||||
);
|
||||
setCollectionArray(prevCollectionArray => {
|
||||
const updatedCollectionArray = prevCollectionArray.map(item => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
...item,
|
||||
[col]: val,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(updatedCollectionArray);
|
||||
if (onChange) {
|
||||
onChange(updatedCollectionArray);
|
||||
}
|
||||
|
||||
return updatedCollectionArray;
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const changeCollection = useCallback(
|
||||
(
|
||||
newCollection: Record<PropertyKey, CollectionItem>,
|
||||
currentCollectionArray: CollectionItem[],
|
||||
) => {
|
||||
// Preserve existing order instead of recreating from Object.keys()
|
||||
const existingIds = new Set(currentCollectionArray.map(item => item.id));
|
||||
const newCollectionArray: CollectionItem[] = [];
|
||||
|
||||
// First pass: preserve existing order and update items
|
||||
for (const existingItem of currentCollectionArray) {
|
||||
if (newCollection[existingItem.id]) {
|
||||
newCollectionArray.push(newCollection[existingItem.id]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
collection: updatedCollection,
|
||||
collectionArray: updatedCollectionArray,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onAddItem() {
|
||||
if (this.props.itemGenerator) {
|
||||
let newItem = this.props.itemGenerator();
|
||||
// Second pass: add new items
|
||||
for (const item of Object.values(newCollection)) {
|
||||
if (!existingIds.has(item.id)) {
|
||||
newCollectionArray.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
setCollection(newCollection);
|
||||
setCollectionArray(newCollectionArray);
|
||||
|
||||
if (onChange) {
|
||||
onChange(newCollectionArray);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const deleteItem = useCallback(
|
||||
(id: string | number) => {
|
||||
setCollection(prevCollection => {
|
||||
const newColl = { ...prevCollection };
|
||||
delete newColl[id];
|
||||
return newColl;
|
||||
});
|
||||
|
||||
setCollectionArray(prevCollectionArray => {
|
||||
const newCollectionArray = prevCollectionArray.filter(
|
||||
item => item.id !== id,
|
||||
);
|
||||
|
||||
if (onChange) {
|
||||
onChange(newCollectionArray);
|
||||
}
|
||||
|
||||
return newCollectionArray;
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const onAddItem = useCallback(() => {
|
||||
if (itemGenerator) {
|
||||
let newItem = itemGenerator() as CollectionItem;
|
||||
const shouldStartExpanded = newItem.expanded === true;
|
||||
if (!newItem.id) {
|
||||
if (newItem.id == null) {
|
||||
newItem = { ...newItem, id: nanoid() };
|
||||
}
|
||||
delete newItem.expanded;
|
||||
|
||||
this.setState(
|
||||
prevState => {
|
||||
const newCollection = {
|
||||
...prevState.collection,
|
||||
[newItem.id]: newItem,
|
||||
};
|
||||
const newExpandedColumns = shouldStartExpanded
|
||||
? { ...prevState.expandedColumns, [newItem.id]: true }
|
||||
: prevState.expandedColumns;
|
||||
const newCollectionArray = [newItem, ...prevState.collectionArray];
|
||||
setCollection(prevCollection => ({
|
||||
...prevCollection,
|
||||
[newItem.id]: newItem,
|
||||
}));
|
||||
|
||||
return {
|
||||
collection: newCollection,
|
||||
collectionArray: newCollectionArray,
|
||||
expandedColumns: newExpandedColumns,
|
||||
};
|
||||
},
|
||||
() => {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(this.state.collectionArray);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
setCollectionArray(prevCollectionArray => {
|
||||
const newCollectionArray = [newItem, ...prevCollectionArray];
|
||||
|
||||
onFieldsetChange(item: any) {
|
||||
this.changeCollection({
|
||||
...this.state.collection,
|
||||
[item.id]: item,
|
||||
});
|
||||
}
|
||||
|
||||
getLabel(col: any): string {
|
||||
const { columnLabels } = this.props;
|
||||
let label = columnLabels?.[col] ? columnLabels[col] : col;
|
||||
if (label.startsWith('__')) {
|
||||
label = '';
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
getTooltip(col: string): string | undefined {
|
||||
const { columnLabelTooltips } = this.props;
|
||||
return columnLabelTooltips?.[col];
|
||||
}
|
||||
|
||||
changeCollection(collection: any) {
|
||||
// Preserve existing order instead of recreating from Object.keys()
|
||||
const existingIds = new Set(
|
||||
this.state.collectionArray.map(item => item.id),
|
||||
);
|
||||
const newCollectionArray: CollectionItem[] = [];
|
||||
|
||||
// First pass: preserve existing order and update items
|
||||
for (const existingItem of this.state.collectionArray) {
|
||||
if (collection[existingItem.id]) {
|
||||
newCollectionArray.push(collection[existingItem.id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: add new items
|
||||
for (const item of Object.values(collection) as CollectionItem[]) {
|
||||
if (!existingIds.has(item.id)) {
|
||||
newCollectionArray.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ collection, collectionArray: newCollectionArray });
|
||||
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(newCollectionArray);
|
||||
}
|
||||
}
|
||||
|
||||
deleteItem(id: string | number) {
|
||||
const newColl = { ...this.state.collection };
|
||||
delete newColl[id];
|
||||
this.changeCollection(newColl);
|
||||
}
|
||||
|
||||
toggleExpand(id: any) {
|
||||
this.setState(prevState => ({
|
||||
expandedColumns: {
|
||||
...prevState.expandedColumns,
|
||||
[id]: !prevState.expandedColumns[id],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
handleTableChange(
|
||||
pagination: TablePaginationConfig,
|
||||
_filters: Record<string, FilterValue | null>,
|
||||
sorter: SorterResult<CollectionItem> | SorterResult<CollectionItem>[],
|
||||
) {
|
||||
// Handle pagination changes
|
||||
if (pagination.current !== undefined && pagination.pageSize !== undefined) {
|
||||
this.setState({
|
||||
currentPage: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle sorting changes
|
||||
const columnSorter = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
let newSortColumn = '';
|
||||
let newSortOrder = 0;
|
||||
|
||||
if (columnSorter?.columnKey && columnSorter?.order) {
|
||||
newSortColumn = columnSorter.columnKey as string;
|
||||
newSortOrder = columnSorter.order === 'ascend' ? 1 : 2;
|
||||
}
|
||||
|
||||
const { sortColumns } = this.props;
|
||||
const col = newSortColumn;
|
||||
|
||||
if (sortColumns?.includes(col) || newSortOrder === 0) {
|
||||
let sortedArray = [...this.props.collection];
|
||||
|
||||
if (newSortOrder !== 0) {
|
||||
const compareSort = (m: Sort, n: Sort) => {
|
||||
if (typeof m === 'string' && typeof n === 'string') {
|
||||
return (m || '').localeCompare(n || '');
|
||||
}
|
||||
if (typeof m === 'number' && typeof n === 'number') {
|
||||
return m - n;
|
||||
}
|
||||
if (typeof m === 'boolean' && typeof n === 'boolean') {
|
||||
return m === n ? 0 : m ? 1 : -1;
|
||||
}
|
||||
const mStr = String(m ?? '');
|
||||
const nStr = String(n ?? '');
|
||||
return mStr.localeCompare(nStr);
|
||||
};
|
||||
|
||||
sortedArray.sort((a: any, b: any) => compareSort(a[col], b[col]));
|
||||
if (newSortOrder === 2) {
|
||||
sortedArray.reverse();
|
||||
if (onChange) {
|
||||
onChange(newCollectionArray);
|
||||
}
|
||||
} else {
|
||||
const { collectionArray } = createKeyedCollection(
|
||||
this.props.collection,
|
||||
);
|
||||
sortedArray = collectionArray;
|
||||
|
||||
return newCollectionArray;
|
||||
});
|
||||
|
||||
if (shouldStartExpanded) {
|
||||
setExpandedColumns(prev => ({ ...prev, [newItem.id]: true }));
|
||||
}
|
||||
}
|
||||
}, [itemGenerator, onChange]);
|
||||
|
||||
const onFieldsetChange = useCallback(
|
||||
(item: CollectionItem) => {
|
||||
changeCollection(
|
||||
{
|
||||
...collection,
|
||||
[item.id]: item,
|
||||
},
|
||||
collectionArray,
|
||||
);
|
||||
},
|
||||
[changeCollection, collection, collectionArray],
|
||||
);
|
||||
|
||||
const getLabel = useCallback(
|
||||
(col: string): string => {
|
||||
let label = columnLabels?.[col] ? columnLabels[col] : col;
|
||||
if (label.startsWith('__')) {
|
||||
label = '';
|
||||
}
|
||||
return label;
|
||||
},
|
||||
[columnLabels],
|
||||
);
|
||||
|
||||
const getTooltip = useCallback(
|
||||
(col: string): string | undefined => columnLabelTooltips?.[col],
|
||||
[columnLabelTooltips],
|
||||
);
|
||||
|
||||
const toggleExpand = useCallback((id: string | number) => {
|
||||
setExpandedColumns(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
(
|
||||
paginationEvt: TablePaginationConfig,
|
||||
_filters: Record<string, FilterValue | null>,
|
||||
sorter: SorterResult<CollectionItem> | SorterResult<CollectionItem>[],
|
||||
) => {
|
||||
if (
|
||||
paginationEvt.current !== undefined &&
|
||||
paginationEvt.pageSize !== undefined
|
||||
) {
|
||||
setCurrentPage(paginationEvt.current);
|
||||
setPageSize(paginationEvt.pageSize);
|
||||
}
|
||||
const columnSorter = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
let newSortColumn = '';
|
||||
let newSortOrder = SortOrderEnum.Unsorted;
|
||||
|
||||
if (columnSorter?.columnKey && columnSorter?.order) {
|
||||
newSortColumn = columnSorter.columnKey as string;
|
||||
newSortOrder =
|
||||
columnSorter.order === 'ascend'
|
||||
? SortOrderEnum.Asc
|
||||
: SortOrderEnum.Desc;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
collectionArray: sortedArray,
|
||||
sortColumn: newSortColumn,
|
||||
sort: newSortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
const col = newSortColumn;
|
||||
|
||||
renderExpandableSection(item: any): ReactNode {
|
||||
const propsGenerator = () => ({ item, onChange: this.onFieldsetChange });
|
||||
return recurseReactClone(
|
||||
this.props.expandFieldset,
|
||||
Fieldset,
|
||||
propsGenerator,
|
||||
);
|
||||
}
|
||||
if (
|
||||
sortColumns?.includes(col) ||
|
||||
newSortOrder === SortOrderEnum.Unsorted
|
||||
) {
|
||||
let sortedArray = [...propsCollection] as CollectionItem[];
|
||||
|
||||
renderCell(record: any, col: any): ReactNode {
|
||||
const renderer = this.props.itemRenderers?.[col];
|
||||
const val = record[col];
|
||||
const onChange = this.onCellChange.bind(this, record.id, col);
|
||||
return renderer ? renderer(val, onChange, this.getLabel(col), record) : val;
|
||||
}
|
||||
if (newSortOrder !== SortOrderEnum.Unsorted) {
|
||||
const compareSort = (m: Sort, n: Sort) => {
|
||||
if (typeof m === 'string' && typeof n === 'string') {
|
||||
return (m || '').localeCompare(n || '');
|
||||
}
|
||||
if (typeof m === 'number' && typeof n === 'number') {
|
||||
return m - n;
|
||||
}
|
||||
if (typeof m === 'boolean' && typeof n === 'boolean') {
|
||||
return m === n ? 0 : m ? 1 : -1;
|
||||
}
|
||||
const mStr = String(m ?? '');
|
||||
const nStr = String(n ?? '');
|
||||
return mStr.localeCompare(nStr);
|
||||
};
|
||||
|
||||
buildTableColumns() {
|
||||
const { tableColumns, allowDeletes, sortColumns = [] } = this.props;
|
||||
sortedArray.sort((a: CollectionItem, b: CollectionItem) =>
|
||||
compareSort(a[col] as Sort, b[col] as Sort),
|
||||
);
|
||||
if (newSortOrder === SortOrderEnum.Desc) {
|
||||
sortedArray.reverse();
|
||||
}
|
||||
} else {
|
||||
const { collectionArray: resetArray } =
|
||||
createKeyedCollection(propsCollection);
|
||||
sortedArray = resetArray;
|
||||
}
|
||||
|
||||
const antdColumns: ColumnsType = tableColumns.map(col => {
|
||||
const label = this.getLabel(col);
|
||||
const tooltip = this.getTooltip(col);
|
||||
setCollectionArray(sortedArray);
|
||||
setSortColumn(newSortColumn);
|
||||
setSort(newSortOrder);
|
||||
}
|
||||
},
|
||||
[propsCollection, sortColumns],
|
||||
);
|
||||
|
||||
const renderExpandableSection = useCallback(
|
||||
(item: CollectionItem): ReactNode => {
|
||||
const propsGenerator = () => ({ item, onChange: onFieldsetChange });
|
||||
return recurseReactClone(expandFieldset, Fieldset, propsGenerator);
|
||||
},
|
||||
[expandFieldset, onFieldsetChange],
|
||||
);
|
||||
|
||||
const renderCell = useCallback(
|
||||
(record: CollectionItem, col: string): ReactNode => {
|
||||
const renderer = itemRenderers?.[col];
|
||||
const val = record[col];
|
||||
const cellOnChange = (newVal: unknown) =>
|
||||
onCellChange(record.id, col, newVal);
|
||||
return renderer
|
||||
? renderer(val, cellOnChange, getLabel(col), record)
|
||||
: (val as ReactNode);
|
||||
},
|
||||
[itemRenderers, onCellChange, getLabel],
|
||||
);
|
||||
|
||||
const antdColumns = useMemo((): ColumnsType<CollectionItem> => {
|
||||
const columns: ColumnsType<CollectionItem> = tableColumns.map(col => {
|
||||
const label = getLabel(col);
|
||||
const tooltip = getTooltip(col);
|
||||
const isSortable = sortColumns.includes(col);
|
||||
const currentSortOrder: SortOrder | null | undefined =
|
||||
this.state.sortColumn === col
|
||||
? this.state.sort === 1
|
||||
sortColumn === col
|
||||
? sort === SortOrderEnum.Asc
|
||||
? 'ascend'
|
||||
: this.state.sort === 2
|
||||
: sort === SortOrderEnum.Desc
|
||||
? 'descend'
|
||||
: null
|
||||
: null;
|
||||
@@ -361,10 +415,10 @@ export default class CRUDCollection extends PureComponent<
|
||||
)}
|
||||
</>
|
||||
),
|
||||
render: (text: any, record: CollectionItem) =>
|
||||
this.renderCell(record, col),
|
||||
render: (_text: unknown, record: CollectionItem) =>
|
||||
renderCell(record, col),
|
||||
onCell: (record: CollectionItem) => {
|
||||
const cellPropsFn = this.props.itemCellProps?.[col];
|
||||
const cellPropsFn = itemCellProps?.[col];
|
||||
const val = record[col];
|
||||
return cellPropsFn ? cellPropsFn(val, label, record) : {};
|
||||
},
|
||||
@@ -374,7 +428,7 @@ export default class CRUDCollection extends PureComponent<
|
||||
});
|
||||
|
||||
if (allowDeletes) {
|
||||
antdColumns.push({
|
||||
columns.push({
|
||||
key: '__actions',
|
||||
dataIndex: '__actions',
|
||||
sorter: false,
|
||||
@@ -398,7 +452,7 @@ export default class CRUDCollection extends PureComponent<
|
||||
data-test="crud-delete-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => this.deleteItem(record.id)}
|
||||
onClick={() => deleteItem(record.id)}
|
||||
iconSize="l"
|
||||
iconColor="inherit"
|
||||
/>
|
||||
@@ -407,103 +461,112 @@ export default class CRUDCollection extends PureComponent<
|
||||
});
|
||||
}
|
||||
|
||||
return antdColumns as ColumnsType<CollectionItem>;
|
||||
}
|
||||
return columns;
|
||||
}, [
|
||||
tableColumns,
|
||||
getLabel,
|
||||
getTooltip,
|
||||
sortColumns,
|
||||
sortColumn,
|
||||
sort,
|
||||
renderCell,
|
||||
itemCellProps,
|
||||
allowDeletes,
|
||||
deleteItem,
|
||||
]);
|
||||
|
||||
render() {
|
||||
const {
|
||||
stickyHeader,
|
||||
emptyMessage = t('No items'),
|
||||
expandFieldset,
|
||||
pagination = false,
|
||||
filterTerm,
|
||||
filterFields,
|
||||
} = this.props;
|
||||
const displayData = useMemo(() => {
|
||||
if (filterTerm && filterFields?.length) {
|
||||
return collectionArray.filter(item =>
|
||||
filterFields.some(field =>
|
||||
String(item[field] ?? '')
|
||||
.toLowerCase()
|
||||
.includes(filterTerm.toLowerCase()),
|
||||
),
|
||||
);
|
||||
}
|
||||
return collectionArray;
|
||||
}, [collectionArray, filterTerm, filterFields]);
|
||||
|
||||
const displayData =
|
||||
filterTerm && filterFields?.length
|
||||
? this.state.collectionArray.filter(item =>
|
||||
filterFields.some(field =>
|
||||
String(item[field] ?? '')
|
||||
.toLowerCase()
|
||||
.includes(filterTerm.toLowerCase()),
|
||||
),
|
||||
)
|
||||
: this.state.collectionArray;
|
||||
|
||||
const tableColumns = this.buildTableColumns();
|
||||
const expandedRowKeys = Object.keys(this.state.expandedColumns).filter(
|
||||
id => this.state.expandedColumns[id],
|
||||
);
|
||||
|
||||
const expandableConfig = expandFieldset
|
||||
? {
|
||||
expandedRowRender: (record: CollectionItem) =>
|
||||
this.renderExpandableSection(record),
|
||||
rowExpandable: () => true,
|
||||
expandedRowKeys,
|
||||
onExpand: (expanded: boolean, record: CollectionItem) => {
|
||||
this.toggleExpand(record.id);
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Build controlled pagination config, clamping currentPage to valid range
|
||||
// based on displayData (filtered) length, not the full collection
|
||||
const { pageSize, currentPage: statePage } = this.state;
|
||||
const paginationConfig = useMemo((): false | TablePaginationConfig => {
|
||||
if (pagination === false || pagination === undefined) {
|
||||
return false;
|
||||
}
|
||||
// Clamp currentPage to the valid range based on the filtered data
|
||||
// length — without this, filtering down to fewer rows could leave the
|
||||
// user on an empty page until they click somewhere.
|
||||
const totalItems = displayData.length;
|
||||
const maxPage = totalItems > 0 ? Math.ceil(totalItems / pageSize) : 1;
|
||||
const currentPage = Math.min(statePage, maxPage);
|
||||
const paginationConfig: false | TablePaginationConfig | undefined =
|
||||
pagination === false || pagination === undefined
|
||||
? pagination
|
||||
: {
|
||||
...(typeof pagination === 'object' ? pagination : {}),
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
total: totalItems,
|
||||
};
|
||||
const clampedPage = Math.min(currentPage, maxPage);
|
||||
return {
|
||||
...(typeof pagination === 'object' ? pagination : {}),
|
||||
current: clampedPage,
|
||||
pageSize,
|
||||
total: totalItems,
|
||||
};
|
||||
}, [pagination, displayData.length, pageSize, currentPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CrudButtonWrapper>
|
||||
{this.props.allowAddItem && (
|
||||
<StyledButtonWrapper>
|
||||
<Button
|
||||
buttonSize="small"
|
||||
buttonStyle="secondary"
|
||||
onClick={this.onAddItem}
|
||||
data-test="add-item-button"
|
||||
>
|
||||
<Icons.PlusOutlined
|
||||
iconSize="m"
|
||||
data-test="crud-add-table-item"
|
||||
/>
|
||||
{t('Add item')}
|
||||
</Button>
|
||||
</StyledButtonWrapper>
|
||||
)}
|
||||
</CrudButtonWrapper>
|
||||
<Table<CollectionItem>
|
||||
data-test="crud-table"
|
||||
columns={tableColumns}
|
||||
data={displayData as CollectionItem[]}
|
||||
rowKey={(record: CollectionItem) => String(record.id)}
|
||||
sticky={stickyHeader}
|
||||
pagination={paginationConfig}
|
||||
onChange={this.handleTableChange}
|
||||
locale={{ emptyText: emptyMessage }}
|
||||
css={
|
||||
stickyHeader &&
|
||||
css`
|
||||
overflow: auto;
|
||||
`
|
||||
const expandedRowKeys = useMemo(
|
||||
() => Object.keys(expandedColumns).filter(id => expandedColumns[id]),
|
||||
[expandedColumns],
|
||||
);
|
||||
|
||||
const expandableConfig = useMemo(
|
||||
() =>
|
||||
expandFieldset
|
||||
? {
|
||||
expandedRowRender: (record: CollectionItem) =>
|
||||
renderExpandableSection(record),
|
||||
rowExpandable: () => true,
|
||||
expandedRowKeys,
|
||||
onExpand: (_expanded: boolean, record: CollectionItem) => {
|
||||
toggleExpand(record.id);
|
||||
},
|
||||
}
|
||||
expandable={expandableConfig}
|
||||
size={TableSize.Middle}
|
||||
tableLayout="auto"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
[expandFieldset, renderExpandableSection, expandedRowKeys, toggleExpand],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CrudButtonWrapper>
|
||||
{allowAddItem && (
|
||||
<StyledButtonWrapper>
|
||||
<Button
|
||||
buttonSize="small"
|
||||
buttonStyle="secondary"
|
||||
onClick={onAddItem}
|
||||
data-test="add-item-button"
|
||||
>
|
||||
<Icons.PlusOutlined
|
||||
iconSize="m"
|
||||
data-test="crud-add-table-item"
|
||||
/>
|
||||
{t('Add item')}
|
||||
</Button>
|
||||
</StyledButtonWrapper>
|
||||
)}
|
||||
</CrudButtonWrapper>
|
||||
<Table<CollectionItem>
|
||||
data-test="crud-table"
|
||||
columns={antdColumns}
|
||||
data={displayData}
|
||||
rowKey={(record: CollectionItem) => String(record.id)}
|
||||
sticky={stickyHeader}
|
||||
pagination={paginationConfig}
|
||||
onChange={handleTableChange}
|
||||
locale={{ emptyText: emptyMessage }}
|
||||
css={
|
||||
stickyHeader &&
|
||||
css`
|
||||
height: 350px;
|
||||
overflow: auto;
|
||||
`
|
||||
}
|
||||
expandable={expandableConfig}
|
||||
size={TableSize.Middle}
|
||||
tableLayout="auto"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -337,7 +337,8 @@ test('calls onChange with empty SQL when switching to physical dataset', async (
|
||||
|
||||
// Assert that the latest onChange call has empty SQL
|
||||
expect(testProps.onChange).toHaveBeenCalled();
|
||||
const updatedDatasource = testProps.onChange.mock.calls[0];
|
||||
const lastCallIndex = testProps.onChange.mock.calls.length - 1;
|
||||
const updatedDatasource = testProps.onChange.mock.calls[lastCallIndex];
|
||||
expect(updatedDatasource[0].sql).toBe('');
|
||||
});
|
||||
|
||||
|
||||
@@ -105,11 +105,12 @@ test('changes currency position from prefix to suffix', async () => {
|
||||
await selectOption('Suffix', 'Currency prefix or suffix');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testProps.onChange).toHaveBeenCalledTimes(1);
|
||||
expect(testProps.onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify the exact call arguments
|
||||
const callArg = testProps.onChange.mock.calls[0][0];
|
||||
// Verify the exact call arguments - check the latest call
|
||||
const lastCallIndex = testProps.onChange.mock.calls.length - 1;
|
||||
const callArg = testProps.onChange.mock.calls[lastCallIndex][0];
|
||||
const metrics = callArg.metrics || [];
|
||||
const updatedMetric = metrics.find(
|
||||
(m: MetricType) => m.currency?.symbolPosition === 'suffix',
|
||||
@@ -126,11 +127,12 @@ test('changes currency symbol from USD to GBP', async () => {
|
||||
await selectOption('£ (GBP)', 'Currency symbol');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testProps.onChange).toHaveBeenCalledTimes(1);
|
||||
expect(testProps.onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify the exact call arguments
|
||||
const callArg = testProps.onChange.mock.calls[0][0];
|
||||
// Verify the exact call arguments - check the latest call
|
||||
const lastCallIndex = testProps.onChange.mock.calls.length - 1;
|
||||
const callArg = testProps.onChange.mock.calls[lastCallIndex][0];
|
||||
const metrics = callArg.metrics || [];
|
||||
const updatedMetric = metrics.find(
|
||||
(m: MetricType) => m.currency?.symbol === 'GBP',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { ErrorAlert } from '../ErrorMessage';
|
||||
import type { ErrorBoundaryProps, ErrorBoundaryState } from './types';
|
||||
|
||||
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- componentDidCatch requires class component
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
|
||||
@@ -178,7 +178,7 @@ export const hydrateDashboard =
|
||||
viz_type: slice.form_data.viz_type,
|
||||
datasource: slice.form_data.datasource,
|
||||
description: slice.description,
|
||||
description_markeddown: slice.description_markeddown,
|
||||
description_markdown: slice.description_markeddown,
|
||||
owners: slice.owners,
|
||||
modified: slice.modified,
|
||||
changed_on: new Date(slice.changed_on).getTime(),
|
||||
|
||||
@@ -32,11 +32,7 @@ fetchMock.put('glob:*/api/v1/dashboard/*/colors*', {});
|
||||
fetchMock.post('glob:*/superset/log/?*', {});
|
||||
|
||||
jest.mock('@visx/responsive', () => ({
|
||||
ParentSize: ({
|
||||
children,
|
||||
}: {
|
||||
children: (props: { width: number }) => JSX.Element;
|
||||
}) => children({ width: 800 }),
|
||||
useParentSize: () => ({ parentRef: { current: null }, width: 800 }),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/containers/DashboardGrid', () => ({
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
NativeFilterType,
|
||||
getLabelsColorMap,
|
||||
} from '@superset-ui/core';
|
||||
import { ParentSize } from '@visx/responsive';
|
||||
import { useParentSize } from '@visx/responsive';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
import DashboardGrid from 'src/dashboard/containers/DashboardGrid';
|
||||
import {
|
||||
@@ -374,9 +374,13 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
[activeKey, childIds, dashboardLayout, handleFocus, renderTabBar, tabIndex],
|
||||
);
|
||||
|
||||
// Hook form, not <ParentSize>: @visx 4.0.0's component clips content taller
|
||||
// than the viewport, which breaks dashboard page scrolling.
|
||||
const { parentRef, width } = useParentSize();
|
||||
|
||||
return (
|
||||
<div className="grid-container" data-test="grid-container">
|
||||
<ParentSize>{renderParentSizeChildren}</ParentSize>
|
||||
<div className="grid-container" data-test="grid-container" ref={parentRef}>
|
||||
{renderParentSizeChildren({ width })}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -246,7 +246,7 @@ test('Removing a tab', async () => {
|
||||
expect(await screen.findByText('Delete dashboard tab?')).toBeInTheDocument();
|
||||
|
||||
expect(props.deleteComponent).not.toHaveBeenCalled();
|
||||
userEvent.click(screen.getByRole('button', { name: 'DELETE' }));
|
||||
userEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
expect(props.deleteComponent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -582,7 +582,7 @@ const Tabs = (props: TabsProps): ReactElement => {
|
||||
show={!!tabToDelete}
|
||||
onHide={handleCancelTabDelete}
|
||||
onHandledPrimaryAction={handleConfirmTabDelete}
|
||||
primaryButtonName={t('DELETE')}
|
||||
primaryButtonName={t('Delete')}
|
||||
primaryButtonStyle="danger"
|
||||
title={t('Delete dashboard tab?')}
|
||||
centered
|
||||
|
||||
@@ -231,10 +231,6 @@ export type Datasource = Dataset & {
|
||||
column_types: GenericDataType[];
|
||||
table_name: string;
|
||||
database?: Database;
|
||||
/** False when the datasource can't return row samples (e.g. semantic views). */
|
||||
supports_samples?: boolean;
|
||||
/** False when the datasource can't answer drill-to-detail requests. */
|
||||
supports_drill_to_detail?: boolean;
|
||||
};
|
||||
export type DatasourcesState = {
|
||||
[key: string]: Datasource;
|
||||
|
||||
@@ -32,8 +32,8 @@ import {
|
||||
} from '@apache-superset/core/theme';
|
||||
import Switchboard from '@superset-ui/switchboard';
|
||||
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
|
||||
import initPreamble from 'src/preamble';
|
||||
import setupClient from 'src/setup/setupClient';
|
||||
import setupPlugins from 'src/setup/setupPlugins';
|
||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||
import { store, USER_LOADED } from 'src/views/store';
|
||||
import { Loading } from '@superset-ui/core/components';
|
||||
@@ -41,7 +41,6 @@ import { ErrorBoundary } from 'src/components';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
|
||||
import {
|
||||
EmbeddedContextProviders,
|
||||
getThemeController,
|
||||
@@ -50,8 +49,27 @@ import { embeddedApi } from './api';
|
||||
import { getDataMaskChangeTrigger } from './utils';
|
||||
import { validateMessageEvent } from './originValidation';
|
||||
|
||||
setupPlugins();
|
||||
setupCodeOverrides({ embedded: true });
|
||||
// Defer plugin setup until after the language pack loads to prevent t() calls in
|
||||
// plugin control panel configs from being cached in English before translations are ready.
|
||||
// Dynamic imports (webpackMode: "eager") keep modules in the same bundle chunk but defer
|
||||
// their evaluation until after initPreamble() resolves, so module-level t() calls in plugin
|
||||
// control panels and setup code run only after translations are available.
|
||||
const pluginsReady = initPreamble()
|
||||
.catch(err => {
|
||||
logging.warn(
|
||||
'Preamble initialization failed, loading plugins without translations.',
|
||||
err,
|
||||
);
|
||||
})
|
||||
.then(async () => {
|
||||
const [{ default: setupPlugins }, { default: setupCodeOverrides }] =
|
||||
await Promise.all([
|
||||
import(/* webpackMode: "eager" */ 'src/setup/setupPlugins'),
|
||||
import(/* webpackMode: "eager" */ 'src/setup/setupCodeOverrides'),
|
||||
]);
|
||||
setupPlugins();
|
||||
setupCodeOverrides({ embedded: true });
|
||||
});
|
||||
|
||||
const debugMode = process.env.WEBPACK_MODE === 'development';
|
||||
const bootstrapData = getBootstrapData();
|
||||
@@ -172,32 +190,34 @@ function start() {
|
||||
method: 'GET',
|
||||
endpoint: '/api/v1/me/roles/',
|
||||
});
|
||||
return getMeWithRole().then(
|
||||
({ result }) => {
|
||||
// fill in some missing bootstrap data
|
||||
// (because at pageload, we don't have any auth yet)
|
||||
// this allows the frontend's permissions checks to work.
|
||||
bootstrapData.user = result;
|
||||
store.dispatch({
|
||||
type: USER_LOADED,
|
||||
user: result,
|
||||
});
|
||||
if (!root) {
|
||||
root = createRoot(appMountPoint);
|
||||
}
|
||||
root.render(<EmbeddedApp />);
|
||||
},
|
||||
err => {
|
||||
// something is most likely wrong with the guest token; reset the guard
|
||||
// so a rehandshake with a valid token can retry.
|
||||
logging.error(err);
|
||||
showFailureMessage(
|
||||
t(
|
||||
'Something went wrong with embedded authentication. Check the dev console for details.',
|
||||
),
|
||||
);
|
||||
started = false;
|
||||
},
|
||||
return pluginsReady.then(() =>
|
||||
getMeWithRole().then(
|
||||
({ result }) => {
|
||||
// fill in some missing bootstrap data
|
||||
// (because at pageload, we don't have any auth yet)
|
||||
// this allows the frontend's permissions checks to work.
|
||||
bootstrapData.user = result;
|
||||
store.dispatch({
|
||||
type: USER_LOADED,
|
||||
user: result,
|
||||
});
|
||||
if (!root) {
|
||||
root = createRoot(appMountPoint);
|
||||
}
|
||||
root.render(<EmbeddedApp />);
|
||||
},
|
||||
err => {
|
||||
// something is most likely wrong with the guest token; reset the guard
|
||||
// so a rehandshake with a valid token can retry.
|
||||
logging.error(err);
|
||||
showFailureMessage(
|
||||
t(
|
||||
'Something went wrong with embedded authentication. Check the dev console for details.',
|
||||
),
|
||||
);
|
||||
started = false;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -197,44 +197,25 @@ export const DataTablesPane = ({
|
||||
children: pane,
|
||||
}));
|
||||
|
||||
// Hide the Samples tab for datasources that don't expose raw rows
|
||||
// (e.g. semantic views). The check is intentionally ``=== false`` so that
|
||||
// datasources from older backends that don't send the flag still show the
|
||||
// tab and preserve current behavior.
|
||||
const showSamplesTab = datasource?.supports_samples !== false;
|
||||
|
||||
// If the datasource swaps to one that doesn't support samples while the
|
||||
// Samples tab is active (e.g. the user picks a semantic view), the tab
|
||||
// disappears from ``tabItems`` and ``activeTabKey`` is orphaned. Fall back
|
||||
// to Results so the panel keeps rendering content.
|
||||
useEffect(() => {
|
||||
if (!showSamplesTab && activeTabKey === ResultTypes.Samples) {
|
||||
setActiveTabKey(ResultTypes.Results);
|
||||
}
|
||||
}, [showSamplesTab, activeTabKey]);
|
||||
const tabItems = [
|
||||
...queryResultsPanes,
|
||||
...(showSamplesTab
|
||||
? [
|
||||
{
|
||||
key: ResultTypes.Samples,
|
||||
label: t('Samples'),
|
||||
children: (
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
isVisible={ResultTypes.Samples === activeTabKey}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
</StyledDiv>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: ResultTypes.Samples,
|
||||
label: t('Samples'),
|
||||
children: (
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
isVisible={ResultTypes.Samples === activeTabKey}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
</StyledDiv>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,7 +21,6 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
import { datasetLabelLower } from 'src/features/semanticLayers/label';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { GridTable } from 'src/components/GridTable';
|
||||
@@ -36,7 +35,7 @@ import {
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
import { SamplesPaneProps } from '../types';
|
||||
|
||||
const ErrorAlertWrapper = styled.div`
|
||||
const Error = styled.pre`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
@@ -156,14 +155,7 @@ export const SamplesPane = ({
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<ErrorAlertWrapper>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load samples')}
|
||||
description={responseError}
|
||||
/>
|
||||
</ErrorAlertWrapper>
|
||||
<Error>{responseError}</Error>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,14 +25,13 @@ import {
|
||||
getClientErrorObject,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { ResultsPaneProps, QueryResultInterface } from '../types';
|
||||
import { SingleQueryResultPane } from './SingleQueryResultPane';
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
|
||||
const ErrorAlertWrapper = styled.div`
|
||||
const Error = styled.pre`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
@@ -158,14 +157,7 @@ export const useResultsPane = ({
|
||||
isLoading={false}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
<ErrorAlertWrapper>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load results')}
|
||||
description={responseError}
|
||||
/>
|
||||
</ErrorAlertWrapper>
|
||||
<Error>{responseError}</Error>
|
||||
</>
|
||||
);
|
||||
return Array(queryCount).fill(err);
|
||||
|
||||
@@ -19,12 +19,7 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
import * as copyUtils from 'src/utils/copy';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
import { DataTablesPane } from '..';
|
||||
@@ -94,48 +89,6 @@ describe('DataTablesPane', () => {
|
||||
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Hides Samples tab when datasource opts out via supports_samples=false', async () => {
|
||||
const props = createDataTablesPaneProps(0);
|
||||
const propsWithoutSamples = {
|
||||
...props,
|
||||
datasource: { ...props.datasource, supports_samples: false },
|
||||
};
|
||||
render(<DataTablesPane {...propsWithoutSamples} />, { useRedux: true });
|
||||
expect(await screen.findByText('Results')).toBeVisible();
|
||||
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Falls back to Results when active Samples tab disappears mid-session', async () => {
|
||||
// Regression for codeant Major finding on PR #41509: a datasource swap
|
||||
// that hides the Samples tab while it was the active tab used to leave
|
||||
// ``activeTabKey === 'samples'`` orphaned, rendering a blank panel.
|
||||
const props = createDataTablesPaneProps(0);
|
||||
const { rerender } = render(<DataTablesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Open the panel and pick the Samples tab.
|
||||
userEvent.click(screen.getByLabelText('Expand data panel'));
|
||||
userEvent.click(await screen.findByText('Samples'));
|
||||
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
|
||||
|
||||
// Swap to a datasource that doesn't support samples (e.g. a semantic
|
||||
// view). The Samples tab should disappear and the panel should land on
|
||||
// Results with content still rendered.
|
||||
rerender(
|
||||
<DataTablesPane
|
||||
{...props}
|
||||
datasource={{ ...props.datasource, supports_samples: false }}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Results')).toBeVisible();
|
||||
// Panel stays expanded and renders Results content rather than going blank.
|
||||
expect(screen.getByLabelText('Collapse data panel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Should copy data table content correctly', async () => {
|
||||
fetchMock.post(
|
||||
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D',
|
||||
|
||||
@@ -84,14 +84,10 @@ describe('SamplesPane', () => {
|
||||
const props = createSamplesPaneProps({
|
||||
datasourceId: 36,
|
||||
});
|
||||
const { findByText, findByRole } = render(<SamplesPane {...props} />, {
|
||||
const { findByText } = render(<SamplesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// The error is now rendered inside an Alert component, with a clear
|
||||
// headline message and the raw error text as the description.
|
||||
expect(await findByRole('alert')).toBeVisible();
|
||||
expect(await findByText('Failed to load samples')).toBeVisible();
|
||||
expect(await findByText('Error: Bad request')).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -29,14 +29,13 @@ import {
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import * as saveModalActions from 'src/explore/actions/saveModalActions';
|
||||
import SaveModal, { PureSaveModal } from 'src/explore/components/SaveModal';
|
||||
import * as dashboardStateActions from 'src/dashboard/actions/dashboardState';
|
||||
import SaveModal, {
|
||||
createRedirectParams,
|
||||
addChartToDashboard,
|
||||
} from 'src/explore/components/SaveModal';
|
||||
import { CHART_WIDTH } from 'src/dashboard/constants';
|
||||
import { GRID_COLUMN_COUNT } from 'src/dashboard/util/constants';
|
||||
|
||||
// Cast PureSaveModal to `any` to allow instantiation with partial props in tests
|
||||
const TestSaveModal = PureSaveModal as any;
|
||||
|
||||
jest.mock('@superset-ui/core/components/Select', () => ({
|
||||
...jest.requireActual('@superset-ui/core/components/Select/AsyncSelect'),
|
||||
AsyncSelect: ({ onChange }: { onChange: (val: any) => void }) => (
|
||||
@@ -400,139 +399,31 @@ test('renders InfoTooltip icon next to Dataset Name label when datasource type i
|
||||
expect(labelContainer).toContainElement(infoTooltip);
|
||||
});
|
||||
|
||||
test('make sure slice_id in the URLSearchParams before the redirect', () => {
|
||||
const myProps = {
|
||||
...defaultProps,
|
||||
slice: { slice_id: 1, slice_name: 'title', owners: [1] },
|
||||
actions: {
|
||||
setFormData: jest.fn(),
|
||||
updateSlice: jest.fn(() => Promise.resolve({ id: 1 })),
|
||||
getSliceDashboards: jest.fn(),
|
||||
},
|
||||
user: { userId: 1 },
|
||||
history: {
|
||||
replace: jest.fn(),
|
||||
},
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
|
||||
const saveModal = new TestSaveModal(myProps);
|
||||
const result = saveModal.handleRedirect(
|
||||
'https://example.com/?name=John&age=30',
|
||||
test('createRedirectParams sets slice_id in the URLSearchParams', () => {
|
||||
const result = createRedirectParams(
|
||||
'?name=John&age=30',
|
||||
{ id: 1 },
|
||||
'overwrite',
|
||||
);
|
||||
expect(result.get('slice_id')).toEqual('1');
|
||||
expect(result.get('save_action')).toEqual('overwrite');
|
||||
});
|
||||
|
||||
test('removes form_data_key from URL parameters after save', () => {
|
||||
const myProps = {
|
||||
...defaultProps,
|
||||
slice: { slice_id: 1, slice_name: 'title', owners: [1] },
|
||||
actions: {
|
||||
setFormData: jest.fn(),
|
||||
updateSlice: jest.fn(() => Promise.resolve({ id: 1 })),
|
||||
getSliceDashboards: jest.fn(),
|
||||
},
|
||||
user: { userId: 1 },
|
||||
history: {
|
||||
replace: jest.fn(),
|
||||
},
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
|
||||
const saveModal = new TestSaveModal(myProps);
|
||||
|
||||
test('createRedirectParams removes form_data_key from URL parameters', () => {
|
||||
// Test with form_data_key in the URL
|
||||
const urlWithFormDataKey = '?form_data_key=12345&other_param=value';
|
||||
const result = saveModal.handleRedirect(urlWithFormDataKey, { id: 1 });
|
||||
const result = createRedirectParams(
|
||||
urlWithFormDataKey,
|
||||
{ id: 1 },
|
||||
'overwrite',
|
||||
);
|
||||
|
||||
// form_data_key should be removed
|
||||
expect(result.has('form_data_key')).toBe(false);
|
||||
// other parameters should remain
|
||||
expect(result.get('other_param')).toEqual('value');
|
||||
expect(result.get('slice_id')).toEqual('1');
|
||||
expect(result.has('save_action')).toBe(false);
|
||||
});
|
||||
|
||||
test('dispatches removeChartState when saving and going to dashboard', async () => {
|
||||
// Spy on the removeChartState action creator
|
||||
const removeChartStateSpy = jest.spyOn(
|
||||
dashboardStateActions,
|
||||
'removeChartState',
|
||||
);
|
||||
|
||||
// Mock the dashboard API response
|
||||
const dashboardId = 123;
|
||||
const dashboardUrl = '/superset/dashboard/test-dashboard/';
|
||||
fetchMock.get(`glob:*/api/v1/dashboard/${dashboardId}*`, {
|
||||
result: {
|
||||
id: dashboardId,
|
||||
dashboard_title: 'Test Dashboard',
|
||||
url: dashboardUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockHistory = {
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
};
|
||||
const chartId = 42;
|
||||
const mockUpdateSlice = jest.fn(() => Promise.resolve({ id: chartId }));
|
||||
const mockSetFormData = jest.fn();
|
||||
|
||||
const myProps = {
|
||||
...defaultProps,
|
||||
slice: { slice_id: 1, slice_name: 'title', owners: [1] },
|
||||
actions: {
|
||||
setFormData: mockSetFormData,
|
||||
updateSlice: mockUpdateSlice,
|
||||
getSliceDashboards: jest.fn(() => Promise.resolve([])),
|
||||
saveSliceFailed: jest.fn(),
|
||||
},
|
||||
user: { userId: 1 },
|
||||
history: mockHistory,
|
||||
dispatch: mockDispatch,
|
||||
};
|
||||
|
||||
const saveModal = new TestSaveModal(myProps);
|
||||
saveModal.state = {
|
||||
action: 'overwrite',
|
||||
newSliceName: 'test chart',
|
||||
datasetName: 'test dataset',
|
||||
dashboard: { label: 'Test Dashboard', value: dashboardId },
|
||||
saveStatus: null,
|
||||
isLoading: false,
|
||||
tabsData: [],
|
||||
};
|
||||
|
||||
// Mock onHide to prevent errors
|
||||
saveModal.onHide = jest.fn();
|
||||
|
||||
// Trigger save and go to dashboard (gotodash = true)
|
||||
await saveModal.saveOrOverwrite(true);
|
||||
|
||||
// Wait for async operations
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSlice).toHaveBeenCalled();
|
||||
expect(mockSetFormData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify removeChartState was called with the correct chart ID
|
||||
expect(removeChartStateSpy).toHaveBeenCalledWith(chartId);
|
||||
|
||||
// Verify the action was dispatched (check the action object directly)
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'REMOVE_CHART_STATE',
|
||||
chartId,
|
||||
});
|
||||
|
||||
// Verify navigation happened
|
||||
expect(mockHistory.push).toHaveBeenCalled();
|
||||
|
||||
// Clean up
|
||||
removeChartStateSpy.mockRestore();
|
||||
expect(result.get('save_action')).toEqual('overwrite');
|
||||
});
|
||||
|
||||
test('disables tab selector when no dashboard selected', () => {
|
||||
@@ -553,234 +444,6 @@ test('renders tab selector when saving as', async () => {
|
||||
expect(tabSelector).toBeDisabled();
|
||||
});
|
||||
|
||||
test('onDashboardChange triggers tabs load for existing dashboard', async () => {
|
||||
const dashboardId = mockEvent.value;
|
||||
|
||||
fetchMock.get(`glob:*/api/v1/dashboard/${dashboardId}/tabs`, {
|
||||
json: {
|
||||
result: {
|
||||
tab_tree: [
|
||||
{ value: 'tab1', title: 'Main Tab' },
|
||||
{ value: 'tab2', title: 'Tab' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const component = new TestSaveModal(defaultProps);
|
||||
const loadTabsMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ value: 'tab1', title: 'Main Tab' }]);
|
||||
component.loadTabs = loadTabsMock;
|
||||
await component.onDashboardChange({
|
||||
value: dashboardId,
|
||||
label: 'Test Dashboard',
|
||||
});
|
||||
expect(loadTabsMock).toHaveBeenCalledWith(dashboardId);
|
||||
});
|
||||
|
||||
test('onTabChange correctly updates selectedTab via forceUpdate', () => {
|
||||
const component = new TestSaveModal(defaultProps);
|
||||
|
||||
component.state = {
|
||||
...component.state,
|
||||
tabsData: [
|
||||
{
|
||||
value: 'tab1',
|
||||
title: 'Main Tab',
|
||||
key: 'tab1',
|
||||
children: [
|
||||
{
|
||||
value: 'tab2',
|
||||
title: 'Analytics Tab',
|
||||
key: 'tab2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
component.setState = function (this: any, stateUpdate: any) {
|
||||
if (typeof stateUpdate === 'function') {
|
||||
this.state = { ...this.state, ...stateUpdate(this.state) };
|
||||
} else {
|
||||
this.state = { ...this.state, ...stateUpdate };
|
||||
}
|
||||
}.bind(component);
|
||||
|
||||
component.onTabChange('tab2');
|
||||
|
||||
expect(component.state.selectedTab).toEqual({
|
||||
value: 'tab2',
|
||||
label: 'Analytics Tab',
|
||||
});
|
||||
});
|
||||
|
||||
const ownerUser = {
|
||||
userId: 1,
|
||||
username: 'testuser',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
isActive: true,
|
||||
isAnonymous: false,
|
||||
permissions: {},
|
||||
roles: { Alpha: [['can_write', 'Dashboard']] as [string, string][] },
|
||||
groups: [],
|
||||
};
|
||||
|
||||
const makeMetadataDashboard = (id: number, title: string) => ({
|
||||
id,
|
||||
dashboard_title: title,
|
||||
owners: [{ id: 1, first_name: 'Test', last_name: 'User' }],
|
||||
extra_owners: [],
|
||||
roles: [],
|
||||
url: `/superset/dashboard/${id}/`,
|
||||
slug: null,
|
||||
thumbnail_url: null,
|
||||
published: true,
|
||||
changed_by_name: 'Test User',
|
||||
changed_by: { id: 1, first_name: 'Test', last_name: 'User' },
|
||||
changed_on: '2024-01-01',
|
||||
charts: [],
|
||||
});
|
||||
|
||||
test('pre-populates dashboard from metadata.dashboards when dashboardId prop is absent', async () => {
|
||||
const dashboardId = 5;
|
||||
const dashboardTitle = 'Chart Dashboard';
|
||||
|
||||
const myProps = {
|
||||
...defaultProps,
|
||||
dashboardId: null,
|
||||
metadata: {
|
||||
dashboards: [{ id: dashboardId, dashboard_title: dashboardTitle }],
|
||||
owners: ['Test User'],
|
||||
created_on_humanized: '2 days ago',
|
||||
changed_on_humanized: '1 day ago',
|
||||
},
|
||||
user: ownerUser,
|
||||
slice: { slice_id: 1, slice_name: 'My Chart', owners: [1] },
|
||||
dispatch: jest.fn(),
|
||||
addDangerToast: jest.fn(),
|
||||
};
|
||||
|
||||
const component = new TestSaveModal(myProps);
|
||||
const mockFull = makeMetadataDashboard(dashboardId, dashboardTitle);
|
||||
|
||||
component.loadDashboard = jest.fn().mockResolvedValue(mockFull);
|
||||
component.loadTabs = jest.fn().mockResolvedValue([]);
|
||||
|
||||
const stateUpdates: any[] = [];
|
||||
component.setState = jest.fn((update: any) => {
|
||||
stateUpdates.push(update);
|
||||
});
|
||||
|
||||
try {
|
||||
sessionStorage.clear();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await component.componentDidMount();
|
||||
|
||||
expect(component.loadDashboard).toHaveBeenCalledWith(dashboardId);
|
||||
expect(stateUpdates).toContainEqual({
|
||||
dashboard: { label: dashboardTitle, value: dashboardId },
|
||||
});
|
||||
expect(component.loadTabs).toHaveBeenCalledWith(dashboardId);
|
||||
});
|
||||
|
||||
test('skips non-editable dashboards and picks the first editable one from metadata', async () => {
|
||||
const editableId = 7;
|
||||
const editableTitle = 'Editable Dashboard';
|
||||
|
||||
const myProps = {
|
||||
...defaultProps,
|
||||
dashboardId: null,
|
||||
metadata: {
|
||||
dashboards: [
|
||||
{ id: 6, dashboard_title: 'Not Mine' },
|
||||
{ id: editableId, dashboard_title: editableTitle },
|
||||
],
|
||||
owners: ['Test User'],
|
||||
created_on_humanized: '2 days ago',
|
||||
changed_on_humanized: '1 day ago',
|
||||
},
|
||||
user: ownerUser,
|
||||
slice: { slice_id: 1, slice_name: 'My Chart', owners: [1] },
|
||||
dispatch: jest.fn(),
|
||||
addDangerToast: jest.fn(),
|
||||
};
|
||||
|
||||
const component = new TestSaveModal(myProps);
|
||||
|
||||
const notMine = makeMetadataDashboard(6, 'Not Mine');
|
||||
notMine.owners = [{ id: 99, first_name: 'Other', last_name: 'Owner' }];
|
||||
const editable = makeMetadataDashboard(editableId, editableTitle);
|
||||
|
||||
component.loadDashboard = jest
|
||||
.fn()
|
||||
.mockImplementation((id: number) =>
|
||||
Promise.resolve(id === 6 ? notMine : editable),
|
||||
);
|
||||
component.loadTabs = jest.fn().mockResolvedValue([]);
|
||||
|
||||
const stateUpdates: any[] = [];
|
||||
component.setState = jest.fn((update: any) => {
|
||||
stateUpdates.push(update);
|
||||
});
|
||||
|
||||
try {
|
||||
sessionStorage.clear();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await component.componentDidMount();
|
||||
|
||||
expect(stateUpdates).toContainEqual({
|
||||
dashboard: { label: editableTitle, value: editableId },
|
||||
});
|
||||
expect(component.loadTabs).toHaveBeenCalledWith(editableId);
|
||||
});
|
||||
|
||||
test('does not use metadata fallback when dashboardId prop is set', async () => {
|
||||
const propDashboardId = 3;
|
||||
const propDashboardTitle = 'Prop Dashboard';
|
||||
|
||||
const myProps = {
|
||||
...defaultProps,
|
||||
dashboardId: propDashboardId,
|
||||
metadata: {
|
||||
dashboards: [{ id: 99, dashboard_title: 'Should Not Be Used' }],
|
||||
owners: ['Test User'],
|
||||
created_on_humanized: '2 days ago',
|
||||
changed_on_humanized: '1 day ago',
|
||||
},
|
||||
user: ownerUser,
|
||||
slice: { slice_id: 1, slice_name: 'My Chart', owners: [1] },
|
||||
dispatch: jest.fn(),
|
||||
addDangerToast: jest.fn(),
|
||||
};
|
||||
|
||||
const component = new TestSaveModal(myProps);
|
||||
const mockFull = makeMetadataDashboard(propDashboardId, propDashboardTitle);
|
||||
|
||||
component.loadDashboard = jest.fn().mockResolvedValue(mockFull);
|
||||
component.loadTabs = jest.fn().mockResolvedValue([]);
|
||||
|
||||
const stateUpdates: any[] = [];
|
||||
component.setState = jest.fn((update: any) => {
|
||||
stateUpdates.push(update);
|
||||
});
|
||||
|
||||
await component.componentDidMount();
|
||||
|
||||
expect(component.loadDashboard).toHaveBeenCalledWith(propDashboardId);
|
||||
expect(component.loadDashboard).not.toHaveBeenCalledWith(99);
|
||||
expect(stateUpdates).toContainEqual({
|
||||
dashboard: { label: propDashboardTitle, value: propDashboardId },
|
||||
});
|
||||
});
|
||||
|
||||
test('chart placement logic finds row with available space', () => {
|
||||
// Test case 1: Row has space (8 + 4 = 12 <= 12)
|
||||
const positionJson1 = {
|
||||
@@ -867,7 +530,7 @@ test('chart placement logic finds row with available space', () => {
|
||||
expect(findRowWithSpace(positionJson3, ['row1'])).toBeNull();
|
||||
});
|
||||
|
||||
test('addChartToDashboardTab successfully adds chart to existing row with space', async () => {
|
||||
test('addChartToDashboard successfully adds chart to existing row with space', async () => {
|
||||
const dashboardId = 123;
|
||||
const chartId = 456;
|
||||
const tabId = 'TABS_ID';
|
||||
@@ -909,18 +572,11 @@ test('addChartToDashboardTab successfully adds chart to existing row with space'
|
||||
json: { result: mockDashboard },
|
||||
});
|
||||
|
||||
const component = new TestSaveModal(defaultProps);
|
||||
|
||||
const mockNanoid = jest.spyOn(require('nanoid'), 'nanoid');
|
||||
mockNanoid.mockReturnValue('test-id');
|
||||
|
||||
try {
|
||||
await component.addChartToDashboardTab(
|
||||
dashboardId,
|
||||
chartId,
|
||||
tabId,
|
||||
sliceName,
|
||||
);
|
||||
await addChartToDashboard(dashboardId, chartId, tabId, sliceName);
|
||||
|
||||
expect(SupersetClient.get).toHaveBeenCalledWith({
|
||||
endpoint: `/api/v1/dashboard/${dashboardId}`,
|
||||
@@ -946,7 +602,7 @@ test('addChartToDashboardTab successfully adds chart to existing row with space'
|
||||
}
|
||||
});
|
||||
|
||||
test('addChartToDashboardTab creates new row when no existing row has space', async () => {
|
||||
test('addChartToDashboard creates new row when no existing row has space', async () => {
|
||||
const dashboardId = 123;
|
||||
const chartId = 456;
|
||||
const tabId = 'TABS_ID';
|
||||
@@ -1000,19 +656,12 @@ test('addChartToDashboardTab creates new row when no existing row has space', as
|
||||
});
|
||||
});
|
||||
|
||||
const component = new TestSaveModal(defaultProps);
|
||||
|
||||
const mockRowId = 'test-row-id';
|
||||
const mockNanoid = jest.spyOn(require('nanoid'), 'nanoid');
|
||||
mockNanoid.mockReturnValueOnce(mockRowId);
|
||||
|
||||
try {
|
||||
await component.addChartToDashboardTab(
|
||||
dashboardId,
|
||||
chartId,
|
||||
tabId,
|
||||
sliceName,
|
||||
);
|
||||
await addChartToDashboard(dashboardId, chartId, tabId, sliceName);
|
||||
|
||||
expect(SupersetClient.put).toHaveBeenCalled();
|
||||
const body = JSON.parse(putRequestBody.body);
|
||||
@@ -1034,7 +683,7 @@ test('addChartToDashboardTab creates new row when no existing row has space', as
|
||||
}
|
||||
});
|
||||
|
||||
test('addChartToDashboardTab handles empty position_json', async () => {
|
||||
test('addChartToDashboard handles empty position_json', async () => {
|
||||
const dashboardId = 123;
|
||||
const chartId = 456;
|
||||
const tabId = 'TABS_ID';
|
||||
@@ -1057,14 +706,12 @@ test('addChartToDashboardTab handles empty position_json', async () => {
|
||||
json: { result: mockDashboard },
|
||||
});
|
||||
|
||||
const component = new TestSaveModal(defaultProps);
|
||||
|
||||
const mockNanoid = jest.spyOn(require('nanoid'), 'nanoid');
|
||||
mockNanoid.mockReturnValue('test-id');
|
||||
|
||||
try {
|
||||
await expect(
|
||||
component.addChartToDashboardTab(dashboardId, chartId, tabId, sliceName),
|
||||
addChartToDashboard(dashboardId, chartId, tabId, sliceName),
|
||||
).rejects.toThrow(`Tab ${tabId} not found in positionJson`);
|
||||
} finally {
|
||||
SupersetClient.get = originalGet;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user