Compare commits

...

30 Commits

Author SHA1 Message Date
David
90fe1f5b7c fix(auth): redirect to login on failure to access dashboard permalink (#40769) 2026-06-28 17:49:25 -07:00
Abdul Rehman
a529945d3b fix(reports): commit permalink before Playwright navigation (#41051) 2026-06-28 17:48:44 -07:00
jesperct
0c12114ea9 fix(explore): stop metric edits from bleeding across metrics with duplicate optionNames (#41208)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:46:13 -07:00
Hans Yu
6eb51105d0 refactor: update SQLAlchemy case() syntax to 2.0 (#40275) 2026-06-28 12:46:02 -07:00
Đỗ Trọng Hải
b2a2698898 chore: unify Node version (#41500) 2026-06-28 12:22:36 -07:00
dependabot[bot]
efed2b09aa chore(deps-dev): bump @types/node from 25.9.3 to 26.0.0 in /superset-frontend (#41451)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 12:15:29 -07:00
dependabot[bot]
c3a11e3170 chore(deps): bump sqlalchemy-utils from 0.42.0 to 0.42.1 (#41478)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-06-28 12:15:14 -07:00
dependabot[bot]
98d61c5cf8 chore(deps-dev): bump duckdb from 1.5.3 to 1.5.4 (#41480)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-06-28 12:14:59 -07:00
dependabot[bot]
7d5e01b6cf chore(deps-dev): update taospy requirement from >=2.7.21 to >=2.8.9 (#41481)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-06-28 12:14:49 -07:00
qlimenoque
a70d055669 feat(helm): use env redis_driver, render additional deployment pod spec (#26040) 2026-06-27 22:44:14 -07:00
Evan Rusackas
670f25e5f4 chore(lint): convert explore controls, SqlLab, and misc components to function components (#39461)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-06-27 22:41:53 -07:00
Evan Rusackas
7fc1113c31 fix(telemetry): use Scarf static pixel instead of gateway redirect (#41129)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-27 22:41:17 -07:00
dependabot[bot]
ccbd284245 chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#41447)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 12:21:53 +07:00
Evan Rusackas
d0268442f8 chore(ci): correct actions/cache pin comment to actual version v5.0.5 (#41482)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:41:55 +07:00
ʈᵃᵢ
25c9f3510a test(mcp): set embedded on update_dashboard test mock (#41495) 2026-06-28 11:19:01 +07:00
Đỗ Trọng Hải
b8fd2e9725 feat(websocket,embedded-sdk): replace Jest with modern Vitest (#38308)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
2026-06-28 11:12:37 +07:00
Evan Rusackas
78dd400ca4 chore(ci): correct actions/cache version comment to match pinned SHA (#41483)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:56:33 +07:00
Evan Rusackas
7587d0778a chore(ci): correct actions/cache version comment to v5.0.5 (#41484)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:56:08 +07:00
Elizabeth Thompson
97cb002f46 fix(a11y): propagate tooltip string as aria-label on IconTooltip button (#41493) 2026-06-27 15:01:46 -07:00
Elizabeth Thompson
5ec0931840 fix(pandas_postprocessing): pass string operator names to GroupBy.agg to avoid FutureWarning (#41025) 2026-06-27 15:01:43 -07:00
Elizabeth Thompson
3eb9185521 fix(viz): use series_limit/series_limit_metric in query_obj dict (#41002)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 15:01:40 -07:00
Shaitan
cd8ac41d16 fix(datasource): validate expressions through the shared adhoc-expression checks (#41427)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:47:59 +01:00
Evan Rusackas
21999bb772 fix(i18n): repair corrupted Romanian catalog so it parses again (#41467)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-27 09:13:39 -04:00
innovark
0a18779280 fix(echarts): format mixed timeseries value labels by assigned axis (#40420)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-27 01:27:41 -07:00
Krishna Chaitanya
a147079043 fix(bigquery): backslash-escape apostrophes in filter values (#38835)
BigQuery rejects filter values containing apostrophes (e.g. O'Brien): the
sqlalchemy-bigquery dialect renders string literals via repr(), which switches
to double-quote delimiters that BigQuery parses as identifiers, causing a
syntax error.

Monkey-patch the dialect's colspecs with a TypeDecorator whose literal_processor
emits single-quoted literals using backslash escaping ('O\'Brien'). Doubled
single quotes ('O''Brien') are NOT valid in BigQuery (parsed as concatenated
literals). Control characters are emitted as named escapes with a \xhh fallback,
since BigQuery forbids literal control chars in quoted strings. Follows the
existing Databricks dialect pattern.

Fixes #35857
2026-06-27 00:55:47 -07:00
Abdul Rehman
ebb32de625 fix(cachekey): use data_cache for chart query result invalidation (#40493) 2026-06-26 18:01:14 -07:00
Onur Taşhan
1280eaee18 fix(mcp): include embedded_uuid in get_dashboard_info response (#41195)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:00:10 -07:00
jesperct
15626a047c fix(sqllab): quote autocomplete table names that need it (#41199) 2026-06-26 17:58:05 -07:00
madhushreeag
dc64716c61 fix(echarts): bring annotations in front and prevent tooltip from covering annotation labels (#41174)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-06-26 16:21:41 -07:00
Evan Rusackas
6f12dbf0e1 feat(api): log rejected related/distinct field access as security events (#41306)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-26 15:45:36 -07:00
127 changed files with 11263 additions and 25778 deletions

View File

@@ -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') }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -1 +0,0 @@
v24.16.0

1
docs/.nvmrc Symbolic link
View File

@@ -0,0 +1 @@
../superset-frontend/.nvmrc

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.17.3](https://img.shields.io/badge/Version-0.17.3-informational?style=flat-square)
![Version: 0.18.0](https://img.shields.io/badge/Version-0.18.0-informational?style=flat-square)
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 |

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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:

View File

@@ -108,7 +108,7 @@ dependencies = [
"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-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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1 +0,0 @@
v24.16.0

View File

@@ -0,0 +1 @@
../superset-frontend/.nvmrc

View File

@@ -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:**

View File

@@ -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: {

View File

@@ -18,9 +18,6 @@
*/
module.exports = {
presets: [
"@babel/preset-typescript",
"@babel/preset-env"
],
presets: ["@babel/preset-typescript", "@babel/preset-env"],
sourceMaps: true,
};

File diff suppressed because it is too large Load Diff

View File

@@ -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"
},

View File

@@ -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 {

View File

@@ -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",
}
};

View File

@@ -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`;
}

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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",
);
});

View File

@@ -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"

View File

@@ -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"],
},
};

View File

@@ -200,7 +200,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",
@@ -11729,12 +11729,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/normalize-package-data": {
@@ -41304,9 +41304,9 @@
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
@@ -44362,7 +44362,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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -45,6 +45,7 @@ export const IconTooltip = forwardRef<HTMLElement, IconTooltipProps>(
}}
buttonStyle="link"
className={`IconTooltip ${className}`}
aria-label={tooltip ?? undefined}
>
{children}
</Button>

View File

@@ -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}

View File

@@ -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();
});

View File

@@ -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}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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';

View File

@@ -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)

View File

@@ -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<

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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(
() =>

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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} />, {

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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"
/>
</>
);
}

View File

@@ -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('');
});

View File

@@ -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',

View File

@@ -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

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { connect } from 'react-redux';
import { PureComponent } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import {
HandlerFunction,
@@ -25,7 +25,7 @@ import {
Payload,
QueryFormData,
} from '@superset-ui/core';
import { SupersetTheme, withTheme } from '@apache-superset/core/theme';
import { SupersetTheme, useTheme } from '@apache-superset/core/theme';
import {
AsyncEsmComponent,
List,
@@ -72,7 +72,7 @@ export interface Props {
value: Annotation[];
onChange: (annotations: Annotation[]) => void;
refreshAnnotationData: (payload: Payload) => void;
theme: SupersetTheme;
theme?: SupersetTheme;
}
export interface PopoverState {
@@ -80,200 +80,200 @@ export interface PopoverState {
addedAnnotationIndex: number | null;
}
const defaultProps = {
vizType: '',
value: [],
annotationError: {},
annotationQuery: {},
onChange: () => {},
};
class AnnotationLayerControl extends PureComponent<Props, PopoverState> {
static defaultProps = defaultProps;
function AnnotationLayerControl({
colorScheme,
annotationError = {},
annotationQuery = {},
vizType = '',
validationErrors,
name,
actions,
value = [],
onChange = () => {},
refreshAnnotationData,
}: Props) {
const theme = useTheme();
const [popoverVisible, setPopoverVisible] = useState<
Record<number | string, boolean>
>({});
const [addedAnnotationIndex, setAddedAnnotationIndex] = useState<
number | null
>(null);
constructor(props: Props) {
super(props);
this.state = {
popoverVisible: {},
addedAnnotationIndex: null,
};
this.addAnnotationLayer = this.addAnnotationLayer.bind(this);
this.removeAnnotationLayer = this.removeAnnotationLayer.bind(this);
this.handleVisibleChange = this.handleVisibleChange.bind(this);
}
componentDidMount() {
// preload the AnnotationLayer component and dependent libraries i.e. mathjs
// componentDidMount - preload the AnnotationLayer component and dependent libraries i.e. mathjs
useEffect(() => {
AnnotationLayer.preload();
}
}, []);
componentDidUpdate(prevProps: Props) {
const { name, annotationError, validationErrors, value } = this.props;
// componentDidUpdate - sync validation errors
useEffect(() => {
if (
(Object.keys(annotationError).length && !validationErrors.length) ||
(!Object.keys(annotationError).length && validationErrors.length)
) {
if (
annotationError !== prevProps.annotationError ||
validationErrors !== prevProps.validationErrors ||
value !== prevProps.value
) {
this.props.actions.setControlValue(
name,
value,
Object.keys(annotationError),
actions.setControlValue(name, value, Object.keys(annotationError));
}
}, [annotationError, validationErrors, value, actions, name]);
const addAnnotationLayer = useCallback(
(originalAnnotation: Annotation | null, newAnnotation: Annotation) => {
let annotations = value;
if (originalAnnotation && annotations.includes(originalAnnotation)) {
annotations = annotations.map(anno =>
anno === originalAnnotation ? newAnnotation : anno,
);
} else {
annotations = [...annotations, newAnnotation];
setAddedAnnotationIndex(annotations.length - 1);
}
refreshAnnotationData({
annotation: newAnnotation,
force: true,
});
onChange(annotations);
},
[value, refreshAnnotationData, onChange],
);
const handleVisibleChange = useCallback(
(visible: boolean, popoverKey: number | string) => {
setPopoverVisible(prev => ({
...prev,
[popoverKey]: visible,
}));
},
[],
);
const removeAnnotationLayer = useCallback(
(annotation: Annotation | null) => {
const annotations = value.filter(anno => anno !== annotation);
// So scrollbar doesnt get stuck on hidden
const element = getSectionContainerElement();
if (element) {
element.style.setProperty('overflow-y', 'auto', 'important');
}
onChange(annotations);
},
[value, onChange],
);
const renderPopover = useCallback(
(
popoverKey: number | string,
annotation: Annotation | null,
error: string,
) => {
const id = annotation?.name || '_new';
return (
<div id={`annotation-pop-${id}`} data-test="popover-content">
<AnnotationLayer
{...(annotation || {})}
error={error}
colorScheme={colorScheme}
vizType={vizType}
addAnnotationLayer={(newAnnotation: Annotation) =>
addAnnotationLayer(annotation, newAnnotation)
}
removeAnnotationLayer={() => removeAnnotationLayer(annotation)}
close={() => {
handleVisibleChange(false, popoverKey);
setAddedAnnotationIndex(null);
}}
/>
</div>
);
},
[
colorScheme,
vizType,
addAnnotationLayer,
removeAnnotationLayer,
handleVisibleChange,
],
);
const renderInfo = useCallback(
(anno: Annotation) => {
if (annotationQuery[anno.name]) {
return (
<Icons.SyncOutlined iconColor={theme.colorPrimary} iconSize="m" />
);
}
}
}
if (annotationError[anno.name]) {
return (
<InfoTooltip
label="validation-errors"
type="error"
tooltip={annotationError[anno.name]}
/>
);
}
if (!anno.show) {
return <span style={{ color: theme.colorError }}> {t('Hidden')} </span>;
}
return '';
},
[annotationQuery, annotationError, theme],
);
addAnnotationLayer = (
originalAnnotation: Annotation | null,
newAnnotation: Annotation,
) => {
let annotations = this.props.value;
if (originalAnnotation && annotations.includes(originalAnnotation)) {
annotations = annotations.map(anno =>
anno === originalAnnotation ? newAnnotation : anno,
);
} else {
annotations = [...annotations, newAnnotation];
this.setState({ addedAnnotationIndex: annotations.length - 1 });
}
const addedAnnotation = useMemo(
() => (addedAnnotationIndex !== null ? value[addedAnnotationIndex] : null),
[addedAnnotationIndex, value],
);
this.props.refreshAnnotationData({
annotation: newAnnotation,
force: true,
});
const annotations = value.map((anno, i) => (
<ControlPopover
key={i}
trigger="click"
title={t('Edit annotation layer')}
css={thm => ({
'&:hover': {
cursor: 'pointer',
backgroundColor: thm.colorFillContentHover,
},
})}
content={renderPopover(i, anno, annotationError[anno.name])}
open={popoverVisible[i]}
onOpenChange={visible => handleVisibleChange(visible, i)}
>
<CustomListItem selectable>
<span>{anno.name}</span>
<span style={{ float: 'right' }}>{renderInfo(anno)}</span>
</CustomListItem>
</ControlPopover>
));
this.props.onChange(annotations);
};
const addLayerPopoverKey = 'add';
handleVisibleChange = (visible: boolean, popoverKey: number | string) => {
this.setState(prevState => ({
popoverVisible: { ...prevState.popoverVisible, [popoverKey]: visible },
}));
};
removeAnnotationLayer(annotation: Annotation | null) {
const annotations = this.props.value.filter(anno => anno !== annotation);
// So scrollbar doesnt get stuck on hidden
const element = getSectionContainerElement();
if (element) {
element.style.setProperty('overflow-y', 'auto', 'important');
}
this.props.onChange(annotations);
}
renderPopover = (
popoverKey: number | string,
annotation: Annotation | null,
error: string,
) => {
const id = annotation?.name || '_new';
return (
<div id={`annotation-pop-${id}`} data-test="popover-content">
<AnnotationLayer
{...(annotation || {})}
error={error}
colorScheme={this.props.colorScheme}
vizType={this.props.vizType}
addAnnotationLayer={(newAnnotation: Annotation) =>
this.addAnnotationLayer(annotation, newAnnotation)
return (
<div>
<List bordered css={thm => ({ borderRadius: thm.borderRadius })}>
{annotations}
<ControlPopover
trigger="click"
content={renderPopover(addLayerPopoverKey, addedAnnotation, '')}
title={t('Add annotation layer')}
open={popoverVisible[addLayerPopoverKey]}
destroyOnHidden
onOpenChange={visible =>
handleVisibleChange(visible, addLayerPopoverKey)
}
removeAnnotationLayer={() => this.removeAnnotationLayer(annotation)}
close={() => {
this.handleVisibleChange(false, popoverKey);
this.setState({ addedAnnotationIndex: null });
}}
/>
</div>
);
};
renderInfo(anno: Annotation) {
const { annotationError, annotationQuery, theme } = this.props;
if (annotationQuery[anno.name]) {
return <Icons.SyncOutlined iconColor={theme.colorPrimary} iconSize="m" />;
}
if (annotationError[anno.name]) {
return (
<InfoTooltip
label="validation-errors"
type="error"
tooltip={annotationError[anno.name]}
/>
);
}
if (!anno.show) {
return <span style={{ color: theme.colorError }}> {t('Hidden')} </span>;
}
return '';
}
render() {
const { addedAnnotationIndex } = this.state;
const addedAnnotation =
addedAnnotationIndex !== null
? this.props.value[addedAnnotationIndex]
: null;
const annotations = this.props.value.map((anno, i) => (
<ControlPopover
key={i}
trigger="click"
title={t('Edit annotation layer')}
css={theme => ({
'&:hover': {
cursor: 'pointer',
backgroundColor: theme.colorFillContentHover,
},
})}
content={this.renderPopover(
i,
anno,
this.props.annotationError[anno.name],
)}
open={this.state.popoverVisible[i]}
onOpenChange={visible => this.handleVisibleChange(visible, i)}
>
<CustomListItem selectable>
<span>{anno.name}</span>
<span style={{ float: 'right' }}>{this.renderInfo(anno)}</span>
</CustomListItem>
</ControlPopover>
));
const addLayerPopoverKey = 'add';
return (
<div>
<List bordered css={theme => ({ borderRadius: theme.borderRadius })}>
{annotations}
<ControlPopover
trigger="click"
content={this.renderPopover(
addLayerPopoverKey,
addedAnnotation,
'',
)}
title={t('Add annotation layer')}
open={this.state.popoverVisible[addLayerPopoverKey]}
destroyOnHidden
onOpenChange={visible =>
this.handleVisibleChange(visible, addLayerPopoverKey)
}
>
<CustomListItem selectable>
<Icons.PlusOutlined
iconSize="m"
data-test="add-annotation-layer-button"
/>
{t('Add annotation layer')}
</CustomListItem>
</ControlPopover>
</List>
</div>
);
}
>
<CustomListItem selectable>
<Icons.PlusOutlined
iconSize="m"
data-test="add-annotation-layer-button"
/>
{t('Add annotation layer')}
</CustomListItem>
</ControlPopover>
</List>
</div>
);
}
// Tried to hook this up through stores/control.jsx instead of using redux
@@ -316,9 +316,11 @@ function mapDispatchToProps(
};
}
const themedAnnotationLayerControl = withTheme(AnnotationLayerControl);
// Was a PureComponent before the FC conversion; preserve shallow-equal skip
// for downstream consumers (the connect HOC compares its own derived props,
// but the component itself still benefits from memo for parent re-renders
// that don't change props).
export default connect(
mapStateToProps,
mapDispatchToProps,
)(themedAnnotationLayerControl);
)(memo(AnnotationLayerControl));

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component, type ReactNode } from 'react';
import { useCallback, type ReactNode } from 'react';
import { styled, css } from '@apache-superset/core/theme';
import { Checkbox } from '@superset-ui/core/components';
import ControlHeader from '../ControlHeader';
@@ -47,32 +47,29 @@ const CheckBoxControlWrapper = styled.div`
`}
`;
export default class CheckboxControl extends Component<CheckboxControlProps> {
static defaultProps = {
value: false,
onChange: () => {},
};
export default function CheckboxControl({
value = false,
label,
onChange = () => {},
...restProps
}: CheckboxControlProps): JSX.Element {
const handleChange = useCallback((): void => {
onChange(!value);
}, [onChange, value]);
onChange = (): void => {
this.props.onChange?.(!this.props.value);
};
const checkbox = <Checkbox onChange={handleChange} checked={!!value} />;
renderCheckbox(): ReactNode {
return <Checkbox onChange={this.onChange} checked={!!this.props.value} />;
}
render(): ReactNode {
if (this.props.label) {
return (
<CheckBoxControlWrapper>
<ControlHeader
{...this.props}
leftNode={this.renderCheckbox()}
onClick={this.onChange}
/>
</CheckBoxControlWrapper>
);
}
return this.renderCheckbox();
if (label) {
return (
<CheckBoxControlWrapper>
<ControlHeader
{...restProps}
label={label}
leftNode={checkbox}
onClick={handleChange}
/>
</CheckBoxControlWrapper>
);
}
return checkbox;
}

View File

@@ -20,10 +20,16 @@
import type React from 'react';
import { Route } from 'react-router-dom';
import fetchMock from 'fetch-mock';
import { DatasourceType, JsonObject, SupersetClient } from '@superset-ui/core';
import {
DatasourceType,
JsonObject,
JsonResponse,
SupersetClient,
} from '@superset-ui/core';
import {
render,
screen,
act,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
@@ -31,11 +37,10 @@ import { fallbackExploreInitialData } from 'src/explore/fixtures';
import type { ColumnObject } from 'src/features/datasets/types';
import DatasourceControl from '.';
// Mock DatasourceEditor to avoid mounting the full 2,500+ line editor tree.
// The heavy editor (CollectionTable, FilterableTable, DatabaseSelector, etc.)
// Mock DatasourceEditor to avoid mounting the full 2500+ line editor tree.
// The heavy editor (with CollectionTable, FilterableTable, DatabaseSelector, etc.)
// causes OOM in CI when rendered repeatedly. These tests only need to verify
// DatasourceControl's callback wiring through the modal save flow.
// Editor internals are tested in DatasourceEditor.test.tsx.
jest.mock('src/components/Datasource/components/DatasourceEditor', () => ({
__esModule: true,
default: () =>
@@ -46,8 +51,16 @@ jest.mock('src/components/Datasource/components/DatasourceEditor', () => ({
),
}));
const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
let originalLocation: Location;
beforeEach(() => {
originalLocation = window.location;
});
afterEach(() => {
jest.restoreAllMocks();
window.location = originalLocation;
try {
const unmatched = fetchMock.callHistory.calls('unmatched');
@@ -59,10 +72,16 @@ afterEach(() => {
}
} finally {
fetchMock.clearHistory().removeRoutes();
jest.restoreAllMocks();
jest.clearAllMocks(); // Clears mock history but keeps spy in place
}
});
afterAll(() => {
// Restore the module-scope SupersetClient.get spy so it doesn't leak its
// mocked behavior into other test files running in the same Jest worker.
SupersetClientGet.mockRestore();
});
interface TestDatasource {
id?: number;
name: string;
@@ -251,16 +270,16 @@ test('Should show SQL Lab for sql_lab role', async () => {
test('Click on Swap dataset option', async () => {
const props = createProps();
jest
.spyOn(SupersetClient, 'get')
.mockImplementation(async ({ endpoint }: { endpoint: string }) => {
SupersetClientGet.mockImplementationOnce(
async ({ endpoint }: { endpoint: string }) => {
if (endpoint.includes('_info')) {
return {
json: { permissions: ['can_read', 'can_write'] },
} as any;
} as unknown as JsonResponse;
}
return { json: { result: [] } } as any;
});
return { json: { result: [] } } as unknown as JsonResponse;
},
);
render(<DatasourceControl {...props} />, {
useRedux: true,
@@ -268,8 +287,9 @@ test('Click on Swap dataset option', async () => {
});
await userEvent.click(screen.getByTestId('datasource-menu-trigger'));
await userEvent.click(screen.getByText('Swap dataset'));
await act(async () => {
await userEvent.click(screen.getByText('Swap dataset'));
});
expect(
screen.getByText(
'Changing the dataset may break the chart if the chart relies on columns or metadata that does not exist in the target dataset',
@@ -287,11 +307,11 @@ test('Click on Edit dataset', async () => {
});
await userEvent.click(screen.getByTestId('datasource-menu-trigger'));
await userEvent.click(screen.getByText('Edit dataset'));
await act(async () => {
await userEvent.click(screen.getByText('Edit dataset'));
});
expect(
await screen.findByTestId('mock-datasource-editor'),
).toBeInTheDocument();
expect(screen.getByTestId('mock-datasource-editor')).toBeInTheDocument();
});
test('Edit dataset should be disabled when user is not admin', async () => {
@@ -336,7 +356,9 @@ test('Click on View in SQL Lab', async () => {
expect(queryByTestId('mock-sqllab-route')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('View in SQL Lab'));
await act(async () => {
await userEvent.click(screen.getByText('View in SQL Lab'));
});
expect(getByTestId('mock-sqllab-route')).toBeInTheDocument();
expect(JSON.parse(`${getByTestId('mock-sqllab-route').textContent}`)).toEqual(
@@ -574,7 +596,7 @@ test('should show forbidden dataset state', () => {
expect(screen.getByText(error.statusText)).toBeVisible();
});
test('should fire onDatasourceSave when saving with new metrics', async () => {
test('should allow creating new metrics in dataset editor', async () => {
const props = createProps({
datasource: { ...mockDatasource, metrics: [] },
});
@@ -584,21 +606,18 @@ test('should fire onDatasourceSave when saving with new metrics', async () => {
useRouter: true,
});
// The GET response after save includes the new metric
await openAndSaveChanges({
...mockDatasource,
metrics: [{ id: 1, metric_name: 'test_metric' }],
});
await waitFor(() => {
expect(props.onDatasourceSave).toHaveBeenCalledWith(
expect.objectContaining({
metrics: [{ id: 1, metric_name: 'test_metric' }],
}),
);
expect(props.onDatasourceSave).toHaveBeenCalled();
});
});
test('should fire onDatasourceSave when saving with removed metrics', async () => {
test('should allow deleting metrics in dataset editor', async () => {
const props = createProps({
datasource: {
...mockDatasource,
@@ -611,12 +630,11 @@ test('should fire onDatasourceSave when saving with removed metrics', async () =
useRouter: true,
});
// The GET response after save reflects the metric was deleted
await openAndSaveChanges({ ...mockDatasource, metrics: [] });
await waitFor(() => {
expect(props.onDatasourceSave).toHaveBeenCalledWith(
expect.objectContaining({ metrics: [] }),
);
expect(props.onDatasourceSave).toHaveBeenCalled();
});
});
@@ -628,14 +646,41 @@ test('should handle metric save confirmation modal', async () => {
useRouter: true,
});
await openAndSaveChanges(mockDatasource);
// Set up fetch mocks for the save flow
fetchMock.removeRoute(getDbWithQuery);
fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery });
fetchMock.removeRoute(putDatasetWithAllMockRouteName);
fetchMock.put(
putDatasetWithAll,
{},
{ name: putDatasetWithAllMockRouteName },
);
fetchMock.removeRoute(getDatasetWithAllMockRouteName);
fetchMock.get(
getDatasetWithAll,
{ result: mockDatasource },
{ name: getDatasetWithAllMockRouteName },
);
// Open edit dataset modal
await userEvent.click(screen.getByTestId('datasource-menu-trigger'));
await userEvent.click(await screen.findByTestId('edit-dataset'));
// Click save to trigger confirmation modal
await userEvent.click(await screen.findByTestId('datasource-modal-save'));
// Verify confirmation modal appears
expect(await screen.findByText('OK')).toBeInTheDocument();
// Confirm save
await userEvent.click(screen.getByText('OK'));
await waitFor(() => {
expect(props.onDatasourceSave).toHaveBeenCalled();
});
});
test('should fire onDatasourceSave callback on save', async () => {
test('should verify DatasourceControl callback fires on save', async () => {
const mockOnDatasourceSave = jest.fn();
const props = createProps({
datasource: mockDatasource,
@@ -647,14 +692,23 @@ test('should fire onDatasourceSave callback on save', async () => {
useRouter: true,
});
expect(screen.getByTestId('datasource-control')).toBeInTheDocument();
await openAndSaveChanges(mockDatasource);
await waitFor(() => {
expect(mockOnDatasourceSave).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.any(Number),
name: expect.any(String),
}),
);
expect(mockOnDatasourceSave).toHaveBeenCalled();
});
// Verify callback received a datasource object
expect(mockOnDatasourceSave).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.any(Number),
name: expect.any(String),
}),
);
});
// Note: Cross-component integration test removed due to complex Redux/user context setup
// The existing callback tests provide sufficient coverage for metric creation workflows
// Future enhancement could add MetricsControl integration when test infrastructure supports it

View File

@@ -18,15 +18,10 @@
* under the License.
*/
import React, { PureComponent } from 'react';
import React, { useState, useCallback } from 'react';
import { DatasourceType, SupersetClient, Datasource } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import {
css,
styled,
withTheme,
type SupersetTheme,
} from '@apache-superset/core/theme';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { getTemporalColumns } from '@superset-ui/chart-controls';
import { getUrlParam } from 'src/utils/urlUtils';
import {
@@ -46,7 +41,6 @@ import { Icons } from '@superset-ui/core/components/Icons';
import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
import { URL_PARAMS } from 'src/constants';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import { datasetLabelLower } from 'src/features/semanticLayers/label';
import {
userHasPermission,
isUserAdmin,
@@ -56,6 +50,7 @@ import ViewQueryModalFooter from 'src/explore/components/controls/ViewQueryModal
import ViewQuery from 'src/explore/components/controls/ViewQuery';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { safeStringify } from 'src/utils/safeStringify';
import { datasetLabelLower } from 'src/features/semanticLayers/label';
import { Link } from 'react-router-dom';
// Extended Datasource interface with all properties used in this component
@@ -94,7 +89,7 @@ interface FormData {
[key: string]: unknown;
}
export interface DatasourceControlProps {
interface DatasourceControlProps {
actions: DatasourceControlActions;
onChange?: () => void;
value?: string | null;
@@ -102,7 +97,6 @@ export interface DatasourceControlProps {
form_data?: FormData;
isEditable?: boolean;
onDatasourceSave?: ((datasource: ExtendedDatasource) => void) | null;
theme: SupersetTheme;
user: User;
// ControlHeader-related props
hovered?: boolean;
@@ -114,20 +108,6 @@ export interface DatasourceControlProps {
name?: string;
}
interface DatasourceControlState {
showEditDatasourceModal: boolean;
showChangeDatasourceModal: boolean;
showSaveDatasetModal: boolean;
showDatasource?: boolean;
}
const defaultProps = {
onChange: () => {},
onDatasourceSave: null,
value: null,
isEditable: true,
};
const getDatasetType = (datasource: ExtendedDatasource): string => {
if (datasource.type === 'query') {
return 'query';
@@ -237,413 +217,388 @@ const preventRouterLinkWhileMetaClicked = (evt: React.MouseEvent) => {
}
};
class DatasourceControl extends PureComponent<
DatasourceControlProps,
DatasourceControlState
> {
static defaultProps = defaultProps;
export default function DatasourceControl({
actions,
onChange = () => {},
value = null,
datasource,
form_data,
isEditable = true,
onDatasourceSave = null,
user,
}: DatasourceControlProps) {
const theme = useTheme();
constructor(props: DatasourceControlProps) {
super(props);
this.state = {
showEditDatasourceModal: false,
showChangeDatasourceModal: false,
showSaveDatasetModal: false,
};
const [showEditDatasourceModal, setShowEditDatasourceModal] = useState(false);
const [showChangeDatasourceModal, setShowChangeDatasourceModal] =
useState(false);
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
const handleDatasourceSave = useCallback(
(savedDatasource: Datasource) => {
// Cast to ExtendedDatasource for the component's internal use
actions.changeDatasource(savedDatasource as ExtendedDatasource);
// Cast datasource for getTemporalColumns which expects Dataset | QueryResponse
const { temporalColumns, defaultTemporalColumn } = getTemporalColumns(
savedDatasource as Parameters<typeof getTemporalColumns>[0],
);
const { columns } = savedDatasource;
// the granularity_sqla might not be a temporal column anymore
const timeCol = form_data?.granularity_sqla;
const isGranularitySqlaTemporal = columns.find(
({ column_name }) => column_name === timeCol,
)?.is_dttm;
// the main_dttm_col might not be a temporal column anymore
const isDefaultTemporal = columns.find(
({ column_name }) => column_name === defaultTemporalColumn,
)?.is_dttm;
// if granularity_sqla is empty or it is not a temporal column anymore
// let's update the control value
if (savedDatasource.type === 'table' && !isGranularitySqlaTemporal) {
const temporalColumn = isDefaultTemporal
? defaultTemporalColumn
: temporalColumns?.[0];
actions.setControlValue('granularity_sqla', temporalColumn || null);
}
if (onDatasourceSave) {
onDatasourceSave(savedDatasource);
}
},
[actions, form_data?.granularity_sqla, onDatasourceSave],
);
const toggleChangeDatasourceModal = useCallback(() => {
setShowChangeDatasourceModal(prev => !prev);
}, []);
const toggleEditDatasourceModal = useCallback(() => {
setShowEditDatasourceModal(prev => !prev);
}, []);
const toggleSaveDatasetModal = useCallback(() => {
setShowSaveDatasetModal(prev => !prev);
}, []);
const handleMenuItemClick = useCallback(
({ key }: { key: string }) => {
switch (key) {
case CHANGE_DATASET:
toggleChangeDatasourceModal();
break;
case EDIT_DATASET:
toggleEditDatasourceModal();
break;
case VIEW_IN_SQL_LAB:
{
const payload = {
datasourceKey: `${datasource.id}__${datasource.type}`,
sql: datasource.sql,
};
SupersetClient.postForm('/sqllab/', {
form_data: safeStringify(payload),
});
}
break;
case SAVE_AS_DATASET:
toggleSaveDatasetModal();
break;
default:
break;
}
},
[
datasource,
toggleChangeDatasourceModal,
toggleEditDatasourceModal,
toggleSaveDatasetModal,
],
);
let extra;
if (datasource?.extra) {
if (typeof datasource.extra === 'string') {
try {
extra = JSON.parse(datasource.extra);
} catch {} // eslint-disable-line no-empty
} else {
extra = datasource.extra; // eslint-disable-line prefer-destructuring
}
}
const isMissingDatasource = !datasource?.id || Boolean(extra?.error);
let isMissingParams = false;
if (isMissingDatasource) {
const datasourceId = getUrlParam(URL_PARAMS.datasourceId);
const sliceId = getUrlParam(URL_PARAMS.sliceId);
if (!datasourceId && !sliceId) {
isMissingParams = true;
}
}
onDatasourceSave = (datasource: Datasource) => {
// Cast to ExtendedDatasource for the component's internal use
this.props.actions.changeDatasource(datasource as ExtendedDatasource);
// Cast datasource for getTemporalColumns which expects Dataset | QueryResponse
const { temporalColumns, defaultTemporalColumn } = getTemporalColumns(
datasource as Parameters<typeof getTemporalColumns>[0],
);
const { columns } = datasource;
// the current granularity_sqla might not be a temporal column anymore
const timeCol = this.props.form_data?.granularity_sqla;
const isGranularitySqlaTemporal = columns.find(
({ column_name }) => column_name === timeCol,
)?.is_dttm;
// the current main_dttm_col might not be a temporal column anymore
const isDefaultTemporal = columns.find(
({ column_name }) => column_name === defaultTemporalColumn,
)?.is_dttm;
const allowEdit =
datasource.owners?.map(o => o.id || o.value).includes(user.userId) ||
isUserAdmin(user);
// if the current granularity_sqla is empty or it is not a temporal column anymore
// let's update the control value
if (datasource.type === 'table' && !isGranularitySqlaTemporal) {
const temporalColumn = isDefaultTemporal
? defaultTemporalColumn
: temporalColumns?.[0];
this.props.actions.setControlValue(
'granularity_sqla',
temporalColumn || null,
);
}
const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access');
if (this.props.onDatasourceSave) {
this.props.onDatasourceSave(datasource);
}
const editText = t('Edit %s', datasetLabelLower());
const requestedQuery = {
datasourceKey: `${datasource.id}__${datasource.type}`,
sql: datasource.sql,
};
toggleShowDatasource = () => {
this.setState(({ showDatasource }) => ({
showDatasource: !showDatasource,
}));
};
toggleChangeDatasourceModal = () => {
this.setState(({ showChangeDatasourceModal }) => ({
showChangeDatasourceModal: !showChangeDatasourceModal,
}));
};
toggleEditDatasourceModal = () => {
this.setState(({ showEditDatasourceModal }) => ({
showEditDatasourceModal: !showEditDatasourceModal,
}));
};
toggleSaveDatasetModal = () => {
this.setState(({ showSaveDatasetModal }) => ({
showSaveDatasetModal: !showSaveDatasetModal,
}));
};
handleMenuItemClick = ({ key }: { key: string }) => {
switch (key) {
case CHANGE_DATASET:
this.toggleChangeDatasourceModal();
break;
case EDIT_DATASET:
this.toggleEditDatasourceModal();
break;
case VIEW_IN_SQL_LAB:
{
const { datasource } = this.props;
const payload = {
datasourceKey: `${datasource.id}__${datasource.type}`,
sql: datasource.sql,
};
SupersetClient.postForm('/sqllab/', {
form_data: safeStringify(payload),
});
}
break;
case SAVE_AS_DATASET:
this.toggleSaveDatasetModal();
break;
default:
break;
}
};
render() {
const {
showChangeDatasourceModal,
showEditDatasourceModal,
showSaveDatasetModal,
} = this.state;
const { datasource, onChange, theme } = this.props;
let extra;
if (datasource?.extra) {
if (typeof datasource.extra === 'string') {
try {
extra = JSON.parse(datasource.extra);
} catch {} // eslint-disable-line no-empty
} else {
extra = datasource.extra; // eslint-disable-line prefer-destructuring
}
}
const isMissingDatasource = !datasource?.id || Boolean(extra?.error);
let isMissingParams = false;
if (isMissingDatasource) {
const datasourceId = getUrlParam(URL_PARAMS.datasourceId);
const sliceId = getUrlParam(URL_PARAMS.sliceId);
if (!datasourceId && !sliceId) {
isMissingParams = true;
}
}
const { user } = this.props;
const allowEdit =
datasource.owners?.map(o => o.id || o.value).includes(user.userId) ||
isUserAdmin(user);
const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access');
const editText = t('Edit %s', datasetLabelLower());
const requestedQuery = {
datasourceKey: `${datasource.id}__${datasource.type}`,
sql: datasource.sql,
};
const defaultDatasourceMenuItems = [];
if (this.props.isEditable && !isMissingDatasource) {
defaultDatasourceMenuItems.push({
key: EDIT_DATASET,
label: !allowEdit ? (
<Tooltip
title={t(
'You must be a %s owner in order to edit. Please reach out to a %s owner to request modifications or edit access.',
datasetLabelLower(),
datasetLabelLower(),
)}
>
{editText}
</Tooltip>
) : (
editText
),
disabled: !allowEdit,
'data-test': 'edit-dataset',
});
}
const defaultDatasourceMenuItems = [];
if (isEditable && !isMissingDatasource) {
defaultDatasourceMenuItems.push({
key: CHANGE_DATASET,
label: t('Swap %s', datasetLabelLower()),
});
if (!isMissingDatasource && canAccessSqlLab) {
defaultDatasourceMenuItems.push({
key: VIEW_IN_SQL_LAB,
label: (
<Link
to={{
pathname: '/sqllab',
state: { requestedQuery },
}}
onClick={preventRouterLinkWhileMetaClicked}
>
{t('View in SQL Lab')}
</Link>
),
});
}
const defaultDatasourceMenu = (
<Menu
onClick={this.handleMenuItemClick}
items={defaultDatasourceMenuItems}
/>
);
const queryDatasourceMenuItems = [
{
key: QUERY_PREVIEW,
label: (
<ModalTrigger
triggerNode={
<div data-test="view-query-menu-item">{t('Query preview')}</div>
}
modalTitle={t('Query preview')}
modalBody={
<ViewQuery
sql={datasource?.sql || datasource?.select_star || ''}
datasource={`${datasource.id}__${datasource.type}`}
/>
}
modalFooter={
<ViewQueryModalFooter
changeDatasource={this.toggleSaveDatasetModal}
datasource={{
id: String(datasource.id),
sql: datasource.sql || '',
type: datasource.type,
}}
/>
}
draggable={false}
resizable={false}
responsive
/>
),
},
];
if (canAccessSqlLab) {
queryDatasourceMenuItems.push({
key: VIEW_IN_SQL_LAB,
label: (
<Link
to={{
pathname: '/sqllab',
state: { requestedQuery },
}}
onClick={preventRouterLinkWhileMetaClicked}
>
{t('View in SQL Lab')}
</Link>
),
});
}
queryDatasourceMenuItems.push({
key: SAVE_AS_DATASET,
label: <span>{t('Save as %s', datasetLabelLower())}</span>,
});
const queryDatasourceMenu = (
<Menu
onClick={this.handleMenuItemClick}
items={queryDatasourceMenuItems}
/>
);
const { health_check_message: healthCheckMessage } = datasource;
const titleText =
isMissingDatasource && !datasource.name
? t('Missing %s', datasetLabelLower())
: getDatasourceTitle(datasource);
const tooltip = titleText;
return (
<Styles data-test="datasource-control" className="DatasourceControl">
<div className="data-container">
{datasourceIconLookup[getDatasetType(datasource)]}
{renderDatasourceTitle(titleText, tooltip)}
{healthCheckMessage && (
<Tooltip title={healthCheckMessage}>
<Icons.WarningOutlined
css={css`
margin-left: ${theme.sizeUnit * 2}px;
`}
iconColor={theme.colorWarning}
/>
</Tooltip>
key: EDIT_DATASET,
label: !allowEdit ? (
<Tooltip
title={t(
'You must be a %s owner in order to edit. Please reach out to a %s owner to request modifications or edit access.',
datasetLabelLower(),
datasetLabelLower(),
)}
{extra?.warning_markdown && (
<WarningIconWithTooltip warningMarkdown={extra.warning_markdown} />
)}
<Dropdown
popupRender={() =>
datasource.type === DatasourceType.Query
? queryDatasourceMenu
: defaultDatasourceMenu
}
trigger={['click']}
data-test="datasource-menu"
>
<Icons.MoreOutlined
iconSize="xl"
iconColor={theme.colorPrimary}
className="datasource-modal-trigger"
data-test="datasource-menu-trigger"
>
{editText}
</Tooltip>
) : (
editText
),
disabled: !allowEdit,
'data-test': 'edit-dataset',
});
}
defaultDatasourceMenuItems.push({
key: CHANGE_DATASET,
label: t('Swap %s', datasetLabelLower()),
});
if (!isMissingDatasource && canAccessSqlLab) {
defaultDatasourceMenuItems.push({
key: VIEW_IN_SQL_LAB,
label: (
<Link
to={{
pathname: '/sqllab',
state: { requestedQuery },
}}
onClick={preventRouterLinkWhileMetaClicked}
>
{t('View in SQL Lab')}
</Link>
),
});
}
const defaultDatasourceMenu = (
<Menu onClick={handleMenuItemClick} items={defaultDatasourceMenuItems} />
);
const queryDatasourceMenuItems = [
{
key: QUERY_PREVIEW,
label: (
<ModalTrigger
triggerNode={
<div data-test="view-query-menu-item">{t('Query preview')}</div>
}
modalTitle={t('Query preview')}
modalBody={
<ViewQuery
sql={datasource?.sql || datasource?.select_star || ''}
datasource={`${datasource.id}__${datasource.type}`}
/>
</Dropdown>
</div>
{/* missing dataset */}
{isMissingDatasource && isMissingParams && (
<div className="error-alert">
<ErrorAlert
type="warning"
message={t('Missing URL parameters')}
description={t(
'The URL is missing the dataset_id or slice_id parameters.',
)}
/>
</div>
)}
{isMissingDatasource && !isMissingParams && (
<div className="error-alert">
{extra?.error ? (
<ErrorMessageWithStackTrace
title={extra.error.statusText || extra.error.message}
subtitle={
extra.error.statusText ? extra.error.message : undefined
}
error={extra.error}
source="explore"
/>
) : (
<ErrorAlert
type="warning"
message={t('Missing %s', datasetLabelLower())}
descriptionPre={false}
descriptionDetailsCollapsed={false}
descriptionDetails={
<>
<p>
{t(
'The %s linked to this chart may have been deleted.',
datasetLabelLower(),
)}
</p>
<p>
<Button
buttonStyle="primary"
onClick={() =>
this.handleMenuItemClick({ key: CHANGE_DATASET })
}
>
{t('Swap %s', datasetLabelLower())}
</Button>
</p>
</>
}
/>
)}
</div>
)}
{showEditDatasourceModal &&
(String(datasource.type) === 'semantic_view' ? (
<SemanticViewEditModal
show={showEditDatasourceModal}
onHide={this.toggleEditDatasourceModal}
onSave={() => this.onDatasourceSave(datasource)}
semanticView={{
id: datasource.id,
table_name: datasource.name,
description: datasource.description,
cache_timeout: datasource.cache_timeout,
}
modalFooter={
<ViewQueryModalFooter
changeDatasource={toggleSaveDatasetModal}
datasource={{
id: String(datasource.id),
sql: datasource.sql || '',
type: datasource.type,
}}
/>
) : (
<DatasourceModal
datasource={datasource}
show={showEditDatasourceModal}
onDatasourceSave={this.onDatasourceSave}
onHide={this.toggleEditDatasourceModal}
/>
))}
{showChangeDatasourceModal && (
<ChangeDatasourceModal
onDatasourceSave={this.onDatasourceSave}
onHide={this.toggleChangeDatasourceModal}
show={showChangeDatasourceModal}
onChange={onChange}
/>
)}
{showSaveDatasetModal && (
<SaveDatasetModal
visible={showSaveDatasetModal}
onHide={this.toggleSaveDatasetModal}
buttonTextOnSave={t('Save')}
buttonTextOnOverwrite={t('Overwrite')}
modalDescription={t(
'Save this query as a virtual dataset to continue exploring',
)}
datasource={getDatasourceAsSaveableDataset(datasource)}
openWindow={false}
formData={this.props.form_data}
/>
)}
</Styles>
);
}
}
}
draggable={false}
resizable={false}
responsive
/>
),
},
];
// withTheme injects the theme prop, so we need to cast the component type
export default withTheme(
DatasourceControl as React.ComponentType<
Omit<DatasourceControlProps, 'theme'>
>,
);
if (canAccessSqlLab) {
queryDatasourceMenuItems.push({
key: VIEW_IN_SQL_LAB,
label: (
<Link
to={{
pathname: '/sqllab',
state: { requestedQuery },
}}
onClick={preventRouterLinkWhileMetaClicked}
>
{t('View in SQL Lab')}
</Link>
),
});
}
queryDatasourceMenuItems.push({
key: SAVE_AS_DATASET,
label: <span>{t('Save as %s', datasetLabelLower())}</span>,
});
const queryDatasourceMenu = (
<Menu onClick={handleMenuItemClick} items={queryDatasourceMenuItems} />
);
const { health_check_message: healthCheckMessage } = datasource;
const titleText =
isMissingDatasource && !datasource.name
? t('Missing %s', datasetLabelLower())
: getDatasourceTitle(datasource);
const tooltip = titleText;
return (
<Styles data-test="datasource-control" className="DatasourceControl">
<div className="data-container">
{datasourceIconLookup[getDatasetType(datasource)]}
{renderDatasourceTitle(titleText, tooltip)}
{healthCheckMessage && (
<Tooltip title={healthCheckMessage}>
<Icons.WarningOutlined
css={css`
margin-left: ${theme.sizeUnit * 2}px;
`}
iconColor={theme.colorWarning}
/>
</Tooltip>
)}
{extra?.warning_markdown && (
<WarningIconWithTooltip warningMarkdown={extra.warning_markdown} />
)}
<Dropdown
popupRender={() =>
datasource.type === DatasourceType.Query
? queryDatasourceMenu
: defaultDatasourceMenu
}
trigger={['click']}
data-test="datasource-menu"
>
<Icons.MoreOutlined
iconSize="xl"
iconColor={theme.colorPrimary}
className="datasource-modal-trigger"
data-test="datasource-menu-trigger"
/>
</Dropdown>
</div>
{/* missing dataset */}
{isMissingDatasource && isMissingParams && (
<div className="error-alert">
<ErrorAlert
type="warning"
message={t('Missing URL parameters')}
description={t(
'The URL is missing the dataset_id or slice_id parameters.',
)}
/>
</div>
)}
{isMissingDatasource && !isMissingParams && (
<div className="error-alert">
{extra?.error ? (
<ErrorMessageWithStackTrace
title={extra.error.statusText || extra.error.message}
subtitle={
extra.error.statusText ? extra.error.message : undefined
}
error={extra.error}
source="explore"
/>
) : (
<ErrorAlert
type="warning"
message={t('Missing %s', datasetLabelLower())}
descriptionPre={false}
descriptionDetailsCollapsed={false}
descriptionDetails={
<>
<p>
{t(
'The %s linked to this chart may have been deleted.',
datasetLabelLower(),
)}
</p>
<p>
<Button
buttonStyle="primary"
onClick={() =>
handleMenuItemClick({ key: CHANGE_DATASET })
}
>
{t('Swap %s', datasetLabelLower())}
</Button>
</p>
</>
}
/>
)}
</div>
)}
{showEditDatasourceModal &&
(String(datasource.type) === 'semantic_view' ? (
<SemanticViewEditModal
show={showEditDatasourceModal}
onHide={toggleEditDatasourceModal}
onSave={() => handleDatasourceSave(datasource)}
semanticView={{
id: datasource.id,
table_name: datasource.name,
description: datasource.description,
cache_timeout: datasource.cache_timeout,
}}
/>
) : (
<DatasourceModal
datasource={datasource}
show={showEditDatasourceModal}
onDatasourceSave={handleDatasourceSave}
onHide={toggleEditDatasourceModal}
/>
))}
{showChangeDatasourceModal && (
<ChangeDatasourceModal
onDatasourceSave={handleDatasourceSave}
onHide={toggleChangeDatasourceModal}
show={showChangeDatasourceModal}
onChange={onChange}
/>
)}
{showSaveDatasetModal && (
<SaveDatasetModal
visible={showSaveDatasetModal}
onHide={toggleSaveDatasetModal}
buttonTextOnSave={t('Save')}
buttonTextOnOverwrite={t('Overwrite')}
modalDescription={t(
'Save this query as a virtual dataset to continue exploring',
)}
datasource={getDatasourceAsSaveableDataset(datasource)}
openWindow={false}
formData={form_data}
/>
)}
</Styles>
);
}

View File

@@ -24,11 +24,15 @@ import {
waitFor,
within,
} from 'spec/helpers/testing-library';
import { Metric } from '@superset-ui/core';
import { useDroppable } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { DndMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndMetricSelect';
import {
DndMetricSelect,
coerceMetrics,
} from 'src/explore/components/controls/DndColumnSelectControl/DndMetricSelect';
import { AGGREGATES } from 'src/explore/constants';
import { EXPRESSION_TYPES } from '../MetricControl/AdhocMetric';
import AdhocMetric, { EXPRESSION_TYPES } from '../MetricControl/AdhocMetric';
import { DndItemType } from '../../DndItemType';
import {
CapturedDroppable,
@@ -99,6 +103,70 @@ const adhocMetricB = {
optionName: 'def',
};
test('coerceMetrics regenerates duplicate optionNames so each metric stays unique', () => {
// A saved chart can carry two adhoc metrics with the same optionName (e.g.
// born from a duplicated metric). Since edits are matched by optionName, the
// duplicates must be split apart on load or editing one overwrites the other.
const dup = 'shared_option';
const result = coerceMetrics(
[
{
expressionType: EXPRESSION_TYPES.SIMPLE,
column: defaultProps.columns[0],
aggregate: AGGREGATES.SUM,
optionName: dup,
},
{
expressionType: EXPRESSION_TYPES.SIMPLE,
column: defaultProps.columns[1],
aggregate: AGGREGATES.AVG,
optionName: dup,
},
] as any,
defaultProps.savedMetrics as unknown as Metric[],
defaultProps.columns,
) as AdhocMetric[];
expect(result).toHaveLength(2);
// First keeps the optionName, second is regenerated to avoid the collision.
expect(result[0].optionName).toBe(dup);
expect(result[1].optionName).not.toBe(dup);
// Each metric definition is otherwise preserved.
expect(result[0].aggregate).toBe(AGGREGATES.SUM);
expect(result[1].aggregate).toBe(AGGREGATES.AVG);
});
test('coerceMetrics regenerates duplicate optionNames for SQL adhoc metrics too', () => {
// The same collision can happen with custom SQL metrics, which take a
// different code path than column-backed metrics but must dedupe the same way.
const dup = 'shared_option';
const result = coerceMetrics(
[
{
expressionType: EXPRESSION_TYPES.SQL,
sqlExpression: 'COUNT(*)',
label: 'count',
optionName: dup,
},
{
expressionType: EXPRESSION_TYPES.SQL,
sqlExpression: 'SUM(value)',
label: 'total',
optionName: dup,
},
] as any,
defaultProps.savedMetrics as unknown as Metric[],
defaultProps.columns,
) as AdhocMetric[];
expect(result).toHaveLength(2);
expect(result[0].optionName).toBe(dup);
expect(result[1].optionName).not.toBe(dup);
// Each metric definition is otherwise preserved.
expect(result[0].sqlExpression).toBe('COUNT(*)');
expect(result[1].sqlExpression).toBe('SUM(value)');
});
test('renders with default props', () => {
render(<DndMetricSelect {...defaultProps} />, {
useDndKit: true,

View File

@@ -30,7 +30,9 @@ import {
import { tn } from '@apache-superset/core/translation';
import { GenericDataType } from '@apache-superset/core/common';
import { ColumnMeta } from '@superset-ui/chart-controls';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocMetric, {
dedupeAdhocMetricOptionName,
} from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocMetricPopoverTrigger from 'src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger';
import MetricDefinitionValue from 'src/explore/components/controls/MetricControl/MetricDefinitionValue';
import {
@@ -52,7 +54,7 @@ const isDictionaryForAdhocMetric = (value: QueryFormMetric) =>
typeof value !== 'string' &&
value.expressionType;
const coerceMetrics = (
export const coerceMetrics = (
addedMetrics: QueryFormMetric | QueryFormMetric[] | undefined | null,
savedMetrics: Metric[],
columns: ColumnMeta[],
@@ -71,6 +73,10 @@ const coerceMetrics = (
},
);
// Metrics are identified by optionName when editing; regenerate any that
// collide so each keeps a unique identity (see dedupeAdhocMetricOptionName).
const seenOptionNames = new Set<string>();
return metricsCompatibleWithDataset.map(metric => {
if (
isSavedMetric(metric) &&
@@ -94,14 +100,20 @@ const coerceMetrics = (
);
if (column) {
// Cast entire config object to handle type mismatch between @superset-ui/core and local types
return new AdhocMetric({
...(metric as unknown as Record<string, unknown>),
column,
} as Record<string, unknown>);
return dedupeAdhocMetricOptionName(
new AdhocMetric({
...(metric as unknown as Record<string, unknown>),
column,
} as Record<string, unknown>),
seenOptionNames,
);
}
}
// Cast to unknown first to handle type mismatch between @superset-ui/core and local AdhocMetric
return new AdhocMetric(metric as unknown as Record<string, unknown>);
return dedupeAdhocMetricOptionName(
new AdhocMetric(metric as unknown as Record<string, unknown>),
seenOptionNames,
);
});
};

View File

@@ -16,12 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component, ReactNode } from 'react';
import {
useState,
useCallback,
useEffect,
useMemo,
type ReactNode,
} from 'react';
import { SupersetClient, ensureIsArray } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { t } from '@apache-superset/core/translation';
import { withTheme, type SupersetTheme } from '@apache-superset/core/theme';
import ControlHeader from 'src/explore/components/ControlHeader';
import AdhocMetric, {
isDictionaryForAdhocMetric,
@@ -30,7 +34,6 @@ import {
Operators,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
} from 'src/explore/constants';
import FilterDefinitionOption from 'src/explore/components/controls/MetricControl/FilterDefinitionOption';
import {
AddControlLabel,
HeaderContainer,
@@ -85,7 +88,10 @@ export interface AdhocFilterControlProps {
filter: AdhocFilter,
allFilters: AdhocFilter[],
) => string | boolean | undefined;
theme?: SupersetTheme;
// Control-header props are forwarded through to <ControlHeader />, mirroring
// the legacy class component's {...this.props} spread (validation styling,
// description/warning tooltips, renderTrigger, hovered state).
[key: string]: unknown;
}
interface FilterOption {
@@ -96,12 +102,6 @@ interface FilterOption {
[key: string]: unknown;
}
interface AdhocFilterControlState {
values: AdhocFilter[];
options: FilterOption[];
partitionColumn: string | null;
}
const { warning } = Modal;
function optionsForSelect(props: AdhocFilterControlProps): FilterOption[] {
@@ -146,71 +146,55 @@ function optionsForSelect(props: AdhocFilterControlProps): FilterOption[] {
);
}
class AdhocFilterControl extends Component<
AdhocFilterControlProps,
AdhocFilterControlState
> {
optionRenderer: (option: FilterOption) => JSX.Element;
valueRenderer: (adhocFilter: AdhocFilter, index: number) => JSX.Element;
constructor(props: AdhocFilterControlProps) {
super(props);
this.onRemoveFilter = this.onRemoveFilter.bind(this);
this.onNewFilter = this.onNewFilter.bind(this);
this.onFilterEdit = this.onFilterEdit.bind(this);
this.moveLabel = this.moveLabel.bind(this);
this.onChange = this.onChange.bind(this);
this.mapOption = this.mapOption.bind(this);
this.getMetricExpression = this.getMetricExpression.bind(this);
this.removeFilter = this.removeFilter.bind(this);
const filters = (this.props.value || []).map(filter =>
function AdhocFilterControl({
label,
name = '',
sections,
operators,
onChange = () => {},
value,
datasource,
columns = [],
savedMetrics = [],
selectedMetrics = [],
canDelete,
...restProps
}: AdhocFilterControlProps) {
const [values, setValues] = useState<AdhocFilter[]>(() =>
(value || []).map(filter =>
isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter,
);
),
);
const [partitionColumn, setPartitionColumn] = useState<string | null>(null);
this.optionRenderer = option => <FilterDefinitionOption option={option} />;
this.valueRenderer = (adhocFilter, index) => (
<AdhocFilterOption
key={index}
index={index}
adhocFilter={adhocFilter}
onFilterEdit={this.onFilterEdit}
options={this.state.options}
sections={this.props.sections}
operators={this.props.operators as Operators[] | undefined}
datasource={this.props.datasource}
onRemoveFilter={e => {
e.stopPropagation();
this.onRemoveFilter(index);
}}
onMoveLabel={this.moveLabel}
onDropLabel={() => this.props.onChange?.(this.state.values)}
partitionColumn={this.state.partitionColumn}
/>
);
this.state = {
values: filters,
options: optionsForSelect(this.props),
partitionColumn: null,
};
}
const options = useMemo(
() =>
optionsForSelect({
columns,
selectedMetrics,
savedMetrics,
}),
[columns, selectedMetrics, savedMetrics],
);
componentDidMount() {
const { datasource } = this.props;
useEffect(() => {
// Clear stale partition state before (re)resolving; only 1-partition-key
// datasources end up setting a value below.
setPartitionColumn(null);
if (datasource && datasource.type === 'table') {
const dbId = datasource.database?.id;
const {
datasource_name: name,
datasource_name: dsName,
catalog,
schema,
is_sqllab_view: isSqllabView,
} = datasource;
if (!isSqllabView && dbId && name && schema) {
if (!isSqllabView && dbId && dsName && schema) {
SupersetClient.get({
endpoint: `/api/v1/database/${dbId}/table_metadata/extra/${toQueryString(
{
name,
name: dsName,
catalog,
schema,
},
@@ -219,14 +203,14 @@ class AdhocFilterControl extends Component<
.then(({ json }) => {
if (json && json.partitions) {
const { partitions } = json;
// for now only show latest_partition option
// when table datasource has only 1 partition key.
// only show latest_partition option when the table datasource
// has exactly 1 partition key.
if (
partitions &&
partitions.cols &&
Object.keys(partitions.cols).length === 1
) {
this.setState({ partitionColumn: partitions.cols[0] });
setPartitionColumn(partitions.cols[0]);
}
}
})
@@ -235,174 +219,205 @@ class AdhocFilterControl extends Component<
});
}
}
}
}, [datasource]);
componentDidUpdate(prevProps: AdhocFilterControlProps): void {
if (this.props.columns !== prevProps.columns) {
this.setState({ options: optionsForSelect(this.props) });
}
if (this.props.value !== prevProps.value) {
this.setState({
values: (this.props.value || []).map(filter =>
useEffect(() => {
if (value !== undefined) {
setValues(
(value || []).map(filter =>
isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter,
),
});
}
}
removeFilter(index: number): void {
const valuesCopy = [...this.state.values];
valuesCopy.splice(index, 1);
this.setState(prevState => ({
...prevState,
values: valuesCopy,
}));
this.props.onChange?.(valuesCopy);
}
onRemoveFilter(index: number): void {
const { canDelete } = this.props;
const { values } = this.state;
const result = canDelete?.(values[index], values);
if (typeof result === 'string') {
warning({ title: t('Warning'), content: result });
return;
}
this.removeFilter(index);
}
onNewFilter(newFilter: FilterOption | AdhocFilter): void {
const mappedOption = this.mapOption(newFilter);
if (mappedOption) {
this.setState(
prevState => ({
...prevState,
values: [...prevState.values, mappedOption],
}),
() => {
this.props.onChange?.(this.state.values);
},
);
}
}
}, [value]);
onFilterEdit(changedFilter: AdhocFilter): void {
this.props.onChange?.(
this.state.values.map(value => {
if (value.filterOptionName === changedFilter.filterOptionName) {
return changedFilter;
}
return value;
}),
);
}
const getMetricExpression = useCallback(
(savedMetricName: string): string => {
const metric = savedMetrics?.find(
savedMetric => savedMetric.metric_name === savedMetricName,
);
return metric?.expression ?? '';
},
[savedMetrics],
);
onChange(opts: FilterOption[] | null): void {
const options = (opts || [])
.map(option => this.mapOption(option))
.filter((option): option is AdhocFilter => option !== null);
this.props.onChange?.(options);
}
const mapOption = useCallback(
(option: FilterOption | AdhocFilter): AdhocFilter | null => {
// already a AdhocFilter, skip
if (option instanceof AdhocFilter) {
return option;
}
// via datasource saved metric
if (option.saved_metric_name) {
return new AdhocFilter({
expressionType: ExpressionTypes.Sql,
subject: getMetricExpression(option.saved_metric_name),
operator:
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GreaterThan].operation,
comparator: 0,
clause: Clauses.Having,
});
}
// has a custom label, meaning it's custom column
if (option.label) {
return new AdhocFilter({
expressionType: ExpressionTypes.Sql,
subject: new AdhocMetric(option).translateToSql(),
operator:
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GreaterThan].operation,
comparator: 0,
clause: Clauses.Having,
});
}
// add a new filter item
if (option.column_name) {
return new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: option.column_name,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
comparator: '',
clause: Clauses.Where,
isNew: true,
});
}
return null;
},
[getMetricExpression],
);
getMetricExpression(savedMetricName: string): string {
const metric = this.props.savedMetrics?.find(
savedMetric => savedMetric.metric_name === savedMetricName,
);
return metric?.expression ?? '';
}
const removeFilter = useCallback(
(index: number) => {
const valuesCopy = [...values];
valuesCopy.splice(index, 1);
setValues(valuesCopy);
onChange?.(valuesCopy);
},
[values, onChange],
);
moveLabel(dragIndex: number, hoverIndex: number): void {
const { values } = this.state;
const onRemoveFilter = useCallback(
(index: number) => {
const result = canDelete?.(values[index], values);
if (typeof result === 'string') {
warning({ title: t('Warning'), content: result });
return;
}
removeFilter(index);
},
[canDelete, values, removeFilter],
);
const newValues = [...values];
[newValues[hoverIndex], newValues[dragIndex]] = [
newValues[dragIndex],
newValues[hoverIndex],
];
this.setState({ values: newValues });
}
const onFilterEdit = useCallback(
(changedFilter: AdhocFilter) => {
onChange?.(
values.map(val => {
if (val.filterOptionName === changedFilter.filterOptionName) {
return changedFilter;
}
return val;
}),
);
},
[values, onChange],
);
mapOption(option: FilterOption | AdhocFilter): AdhocFilter | null {
// already a AdhocFilter, skip
if (option instanceof AdhocFilter) {
return option;
}
// via datasource saved metric
if (option.saved_metric_name) {
return new AdhocFilter({
expressionType: ExpressionTypes.Sql,
subject: this.getMetricExpression(option.saved_metric_name),
operator:
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GreaterThan].operation,
comparator: 0,
clause: Clauses.Having,
});
}
// has a custom label, meaning it's custom column
if (option.label) {
return new AdhocFilter({
expressionType: ExpressionTypes.Sql,
subject: new AdhocMetric(option).translateToSql(),
operator:
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GreaterThan].operation,
comparator: 0,
clause: Clauses.Having,
});
}
// add a new filter item
if (option.column_name) {
return new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: option.column_name,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
comparator: '',
clause: Clauses.Where,
isNew: true,
});
}
return null;
}
const moveLabel = useCallback((dragIndex: number, hoverIndex: number) => {
setValues(prevValues => {
const newValues = [...prevValues];
[newValues[hoverIndex], newValues[dragIndex]] = [
newValues[dragIndex],
newValues[hoverIndex],
];
return newValues;
});
}, []);
addNewFilterPopoverTrigger(trigger: ReactNode): JSX.Element {
return (
const onDropLabel = useCallback(() => {
onChange?.(values);
}, [onChange, values]);
const onNewFilter = useCallback(
(newFilter: FilterOption | AdhocFilter) => {
const mappedOption = mapOption(newFilter);
if (mappedOption) {
const newValues = [...values, mappedOption];
setValues(newValues);
onChange?.(newValues);
}
},
[mapOption, values, onChange],
);
const valueRenderer = useCallback(
(adhocFilter: AdhocFilter, index: number) => (
<AdhocFilterOption
key={index}
index={index}
adhocFilter={adhocFilter}
onFilterEdit={onFilterEdit}
options={options}
sections={sections}
operators={operators as Operators[] | undefined}
datasource={datasource}
onRemoveFilter={e => {
e.stopPropagation();
onRemoveFilter(index);
}}
onMoveLabel={moveLabel}
onDropLabel={onDropLabel}
partitionColumn={partitionColumn}
/>
),
[
onFilterEdit,
options,
sections,
operators,
datasource,
onRemoveFilter,
moveLabel,
onDropLabel,
partitionColumn,
],
);
const addNewFilterPopoverTrigger = useCallback(
(trigger: ReactNode) => (
<AdhocFilterPopoverTrigger
operators={this.props.operators as Operators[] | undefined}
sections={this.props.sections}
operators={operators as Operators[] | undefined}
sections={sections}
adhocFilter={new AdhocFilter({})}
datasource={(this.props.datasource as Record<string, unknown>) || {}}
options={this.state.options}
onFilterEdit={this.onNewFilter}
partitionColumn={this.state.partitionColumn ?? undefined}
datasource={(datasource as Record<string, unknown>) || {}}
options={options}
onFilterEdit={onNewFilter}
partitionColumn={partitionColumn ?? undefined}
>
{trigger}
</AdhocFilterPopoverTrigger>
);
}
),
[operators, sections, datasource, options, onNewFilter, partitionColumn],
);
render() {
return (
<div className="metrics-select" data-test="adhoc-filter-control">
<HeaderContainer>
<ControlHeader {...this.props} name={this.props.name ?? ''} />
</HeaderContainer>
<LabelsContainer>
{[
...(this.state.values.length > 0
? this.state.values.map((value, index) =>
this.valueRenderer(value, index),
)
: []),
this.addNewFilterPopoverTrigger(
<AddControlLabel role="button" data-test="add-filter-button">
<Icons.PlusOutlined iconSize="m" />
{t('Add filter')}
</AddControlLabel>,
),
]}
</LabelsContainer>
</div>
);
}
return (
<div className="metrics-select" data-test="adhoc-filter-control">
<HeaderContainer>
<ControlHeader {...restProps} label={label} name={name} />
</HeaderContainer>
<LabelsContainer>
{[
...(values.length > 0
? values.map((val, index) => valueRenderer(val, index))
: []),
addNewFilterPopoverTrigger(
<AddControlLabel role="button" data-test="add-filter-button">
<Icons.PlusOutlined iconSize="m" />
{t('Add filter')}
</AddControlLabel>,
),
]}
</LabelsContainer>
</div>
);
}
export default withTheme(AdhocFilterControl);
export default AdhocFilterControl;

View File

@@ -17,14 +17,13 @@
* under the License.
*/
import type React from 'react';
import { createRef, Component, type RefObject } from 'react';
import { type SupersetTheme } from '@apache-superset/core/theme';
import { useRef, useState, useCallback, useEffect } from 'react';
import type { SupersetTheme } from '@apache-superset/core/theme';
import { Button, Icons, Select } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import Tabs from '@superset-ui/core/components/Tabs';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
import AdhocFilterEditPopoverSimpleTabContent from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent';
@@ -66,17 +65,6 @@ interface AdhocFilterEditPopoverProps {
requireSave?: boolean;
}
interface AdhocFilterEditPopoverState {
adhocFilter: AdhocFilter;
width: number;
height: number;
activeKey: string;
isSimpleTabValid: boolean;
selectedLayers: LayerOption[];
layerOptions: LayerOption[];
hasLayerFilterScopeChanged: boolean;
}
const FilterPopoverContentContainer = styled.div`
#filter-edit-popover {
max-width: none;
@@ -102,373 +90,344 @@ const LayerSelectContainer = styled.div`
margin-bottom: ${({ theme }) => theme.marginXXL}px;
`;
export default class AdhocFilterEditPopover extends Component<
AdhocFilterEditPopoverProps,
AdhocFilterEditPopoverState
> {
popoverContentRef: RefObject<HTMLDivElement>;
function AdhocFilterEditPopover({
adhocFilter: propsAdhocFilter,
onChange,
onClose,
onResize,
options,
datasource,
partitionColumn,
operators,
requireSave,
...popoverProps
}: AdhocFilterEditPopoverProps) {
const popoverContentRef = useRef<HTMLDivElement>(null);
dragStartX = 0;
const dragStartRef = useRef({
x: 0,
y: 0,
width: 0,
height: 0,
});
dragStartY = 0;
const [adhocFilter, setAdhocFilter] = useState<AdhocFilter>(propsAdhocFilter);
const [width, setWidth] = useState(POPOVER_INITIAL_WIDTH);
const [height, setHeight] = useState(POPOVER_INITIAL_HEIGHT);
const [isSimpleTabValid, setIsSimpleTabValid] = useState(true);
const [selectedLayers, setSelectedLayers] = useState<LayerOption[]>([
{ id: null, value: -1, label: 'All' },
]);
const [layerOptions, setLayerOptions] = useState<LayerOption[]>([]);
const [hasLayerFilterScopeChanged, setHasLayerFilterScopeChanged] =
useState(false);
dragStartWidth = 0;
const loadLayerOptions = useCallback(
(page: number, pageSize: number) => {
const query = rison.encode({
columns: ['id', 'slice_name', 'viz_type'],
filters: [{ col: 'viz_type', opr: 'sw', value: 'deck' }],
page,
page_size: pageSize,
order_column: 'slice_name',
order_direction: 'asc',
});
dragStartHeight = 0;
return SupersetClient.get({
endpoint: `/api/v1/chart/?q=${query}`,
}).then(response => {
if (!response?.json?.result) {
return {
data: [
{
id: null,
value: -1,
label: 'All',
},
],
totalCount: 1,
};
}
constructor(props: AdhocFilterEditPopoverProps) {
super(props);
this.onSave = this.onSave.bind(this);
this.onDragDown = this.onDragDown.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.onAdhocFilterChange = this.onAdhocFilterChange.bind(this);
this.setSimpleTabIsValid = this.setSimpleTabIsValid.bind(this);
this.adjustHeight = this.adjustHeight.bind(this);
this.onTabChange = this.onTabChange.bind(this);
this.loadLayerOptions = this.loadLayerOptions.bind(this);
this.onLayerChange = this.onLayerChange.bind(this);
const deckSlices = (propsAdhocFilter?.deck_slices || []) as number[];
this.state = {
adhocFilter: this.props.adhocFilter,
width: POPOVER_INITIAL_WIDTH,
height: POPOVER_INITIAL_HEIGHT,
activeKey: this.props?.adhocFilter?.expressionType || 'SIMPLE',
isSimpleTabValid: true,
selectedLayers: [{ id: null, value: -1, label: 'All' }],
layerOptions: [],
hasLayerFilterScopeChanged: false,
const list = [
{
id: null,
value: -1,
label: 'All',
},
...response.json.result
.map((item: { id: number; slice_name: string }) => {
const sliceIndex = deckSlices.indexOf(item.id);
return {
id: item.id,
value: sliceIndex >= 0 ? sliceIndex : item.id,
label: item.slice_name,
sliceIndex,
};
})
.filter((item: { sliceIndex: number }) => item.sliceIndex !== -1)
.map(
({
sliceIndex,
...item
}: {
sliceIndex: number;
id: number;
value: number;
label: string;
}) => item,
),
];
return {
data: list,
totalCount: list.length,
};
});
},
[propsAdhocFilter?.deck_slices],
);
const onMouseMove = useCallback(
(e: MouseEvent) => {
onResize();
setWidth(
Math.max(
dragStartRef.current.width + (e.clientX - dragStartRef.current.x),
POPOVER_INITIAL_WIDTH,
),
);
setHeight(
Math.max(
dragStartRef.current.height + (e.clientY - dragStartRef.current.y),
POPOVER_INITIAL_HEIGHT,
),
);
},
[onResize],
);
const onMouseUp = useCallback(() => {
document.removeEventListener('mousemove', onMouseMove);
}, [onMouseMove]);
// Listener lifecycle: re-bind only when the handler identities change.
useEffect(() => {
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('mousemove', onMouseMove);
};
}, [onMouseMove, onMouseUp]);
this.popoverContentRef = createRef();
}
componentDidMount() {
document.addEventListener('mouseup', this.onMouseUp);
// Load layer options if deck_slices exist
const deckSlices = this.props.adhocFilter?.deck_slices as
| number[]
| undefined;
// One-time layer-options load, mirroring the class componentDidMount.
useEffect(() => {
const deckSlices = propsAdhocFilter?.deck_slices as number[] | undefined;
if (deckSlices && deckSlices.length > 0) {
this.loadLayerOptions(0, 100).then(result => {
this.setState({ layerOptions: result.data });
const layerFilterScope = this.props.adhocFilter?.layerFilterScope as
loadLayerOptions(0, 100).then(result => {
setLayerOptions(result.data);
const layerFilterScope = propsAdhocFilter?.layerFilterScope as
| number[]
| undefined;
if (layerFilterScope) {
const selectedLayers = layerFilterScope.map(item => {
const layerOption = result.data.find(
option => option.value === item,
);
return layerOption;
});
this.setState({
selectedLayers: selectedLayers.filter(Boolean) as LayerOption[],
});
const layers = layerFilterScope
.map(item => result.data.find(option => option.value === item))
.filter(Boolean) as LayerOption[];
setSelectedLayers(layers);
}
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot: mirrors componentDidMount, initial load only
}, []);
componentWillUnmount() {
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('mousemove', this.onMouseMove);
}
const onAdhocFilterChange = useCallback((filter: AdhocFilter) => {
setAdhocFilter(filter);
}, []);
onAdhocFilterChange(adhocFilter: AdhocFilter): void {
this.setState({ adhocFilter });
}
const setSimpleTabIsValid = useCallback((isValid: boolean) => {
setIsSimpleTabValid(isValid);
}, []);
setSimpleTabIsValid(isValid: boolean): void {
this.setState({ isSimpleTabValid: isValid });
}
onSave() {
const deckSlices = this.state.adhocFilter.deck_slices as
| number[]
| undefined;
const onSave = useCallback(() => {
const deckSlices = adhocFilter.deck_slices as number[] | undefined;
const hasDeckSlices = deckSlices && deckSlices.length > 0;
if (!hasDeckSlices) {
this.props.onChange(this.state.adhocFilter);
this.props.onClose();
onChange(adhocFilter);
onClose();
return;
}
// Update layer filter scope for deck multi
const selectedLayers = this.state.selectedLayers.map(item => {
const layers = selectedLayers.map(item => {
if (isObject(item)) {
return item.value;
}
return item;
});
const correctedAdhocFilter = this.state.adhocFilter.duplicateWith({
layerFilterScope: selectedLayers,
const correctedAdhocFilter = adhocFilter.duplicateWith({
layerFilterScope: layers,
});
this.setState({ hasLayerFilterScopeChanged: false });
this.props.onChange(correctedAdhocFilter);
this.props.onClose();
}
setHasLayerFilterScopeChanged(false);
onChange(correctedAdhocFilter);
onClose();
}, [adhocFilter, onChange, onClose, selectedLayers]);
onDragDown(e: React.MouseEvent): void {
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragStartWidth = this.state.width;
this.dragStartHeight = this.state.height;
document.addEventListener('mousemove', this.onMouseMove);
}
onMouseMove(e: MouseEvent): void {
this.props.onResize();
this.setState({
width: Math.max(
this.dragStartWidth + (e.clientX - this.dragStartX),
POPOVER_INITIAL_WIDTH,
),
height: Math.max(
this.dragStartHeight + (e.clientY - this.dragStartY),
POPOVER_INITIAL_HEIGHT,
),
});
}
onMouseUp() {
document.removeEventListener('mousemove', this.onMouseMove);
}
onTabChange(activeKey: string) {
this.setState({
activeKey,
});
}
adjustHeight(heightDifference: number) {
this.setState(state => ({ height: state.height + heightDifference }));
}
loadLayerOptions(page: number, pageSize: number) {
const query = rison.encode({
columns: ['id', 'slice_name', 'viz_type'],
filters: [{ col: 'viz_type', opr: 'sw', value: 'deck' }],
page,
page_size: pageSize,
order_column: 'slice_name',
order_direction: 'asc',
});
return SupersetClient.get({
endpoint: `/api/v1/chart/?q=${query}`,
}).then(response => {
if (!response?.json?.result) {
return {
data: [
{
id: null,
value: -1,
label: 'All',
},
],
totalCount: 1,
};
}
const deckSlices = (this.props.adhocFilter?.deck_slices ||
[]) as number[];
const list = [
{
id: null,
value: -1,
label: 'All',
},
...response.json.result
.map((item: { id: number; slice_name: string }) => {
const sliceIndex = deckSlices.indexOf(item.id);
return {
id: item.id,
value: sliceIndex >= 0 ? sliceIndex : item.id,
label: item.slice_name,
sliceIndex,
};
})
.filter((item: { sliceIndex: number }) => item.sliceIndex !== -1)
.map(
({
sliceIndex,
...item
}: {
sliceIndex: number;
id: number;
value: number;
label: string;
}) => item,
),
];
return {
data: list,
totalCount: list.length,
const onDragDown = useCallback(
(e: React.MouseEvent) => {
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
width,
height,
};
});
}
document.addEventListener('mousemove', onMouseMove);
},
[width, height, onMouseMove],
);
onLayerChange(selectedValue: LayerOption[] | number[] | null) {
let updatedSelectedLayers: LayerOption[] =
(selectedValue as LayerOption[]) || [];
const adjustHeight = useCallback((heightDifference: number) => {
setHeight(prevHeight => prevHeight + heightDifference);
}, []);
if (!selectedValue || selectedValue.length === 0) {
updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }];
} else if (
selectedValue.length > 1 &&
selectedValue.some(
(item: LayerOption | number) =>
(typeof item === 'object' && item.value === -1) || item === -1,
)
) {
const lastItem = selectedValue[selectedValue.length - 1];
if (
(typeof lastItem === 'object' && lastItem.value === -1) ||
lastItem === -1
) {
const onLayerChange = useCallback(
(selectedValue: LayerOption[] | number[] | null) => {
let updatedSelectedLayers: LayerOption[] =
(selectedValue as LayerOption[]) || [];
if (!selectedValue || selectedValue.length === 0) {
updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }];
} else {
updatedSelectedLayers = (selectedValue as LayerOption[]).filter(
(item: LayerOption) => item.value !== -1,
);
} else if (
selectedValue.length > 1 &&
selectedValue.some(
(item: LayerOption | number) =>
(typeof item === 'object' && item.value === -1) || item === -1,
)
) {
const lastItem = selectedValue[selectedValue.length - 1];
if (
(typeof lastItem === 'object' && lastItem.value === -1) ||
lastItem === -1
) {
updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }];
} else {
updatedSelectedLayers = (selectedValue as LayerOption[]).filter(
(item: LayerOption) => item.value !== -1,
);
}
}
}
this.setState({ selectedLayers: updatedSelectedLayers });
this.setState({ hasLayerFilterScopeChanged: true });
}
setSelectedLayers(updatedSelectedLayers);
setHasLayerFilterScopeChanged(true);
},
[],
);
render() {
const {
adhocFilter: propsAdhocFilter,
options,
onChange,
onClose,
onResize,
datasource,
partitionColumn,
theme,
operators,
requireSave,
...popoverProps
} = this.props;
const stateIsValid = adhocFilter.isValid();
const hasUnsavedChanges =
requireSave ||
!adhocFilter.equals(propsAdhocFilter) ||
hasLayerFilterScopeChanged;
const { adhocFilter, selectedLayers, hasLayerFilterScopeChanged } =
this.state;
const stateIsValid = adhocFilter.isValid();
const hasUnsavedChanges =
requireSave ||
!adhocFilter.equals(propsAdhocFilter) ||
hasLayerFilterScopeChanged;
const renderDeckSlices = adhocFilter.deck_slices as number[] | undefined;
const hasDeckSlices = renderDeckSlices && renderDeckSlices.length > 0;
const renderDeckSlices = adhocFilter.deck_slices as number[] | undefined;
const hasDeckSlices = renderDeckSlices && renderDeckSlices.length > 0;
return (
<FilterPopoverContentContainer
id="filter-edit-popover"
{...popoverProps}
data-test="filter-edit-popover"
ref={this.popoverContentRef}
>
<Tabs
id="adhoc-filter-edit-tabs"
defaultActiveKey={adhocFilter.expressionType}
className="adhoc-filter-edit-tabs"
data-test="adhoc-filter-edit-tabs"
style={{ minHeight: this.state.height, width: this.state.width }}
allowOverflow
onChange={this.onTabChange}
items={[
{
key: ExpressionTypes.Simple,
label: t('Simple'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSimpleTabContent
operators={operators as Operators[] | undefined}
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={options as ColumnType[]}
datasource={datasource as unknown as Dataset}
onHeightChange={this.adjustHeight}
partitionColumn={partitionColumn}
popoverRef={this.popoverContentRef.current}
validHandler={this.setSimpleTabIsValid}
/>
</ErrorBoundary>
),
},
...(datasource?.type === 'semantic_view'
? []
: [
{
key: ExpressionTypes.Sql,
label: t('Custom SQL'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSqlTabContent
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={this.props.options}
height={this.state.height}
datasource={datasource}
/>
</ErrorBoundary>
),
},
]),
]}
/>
{hasDeckSlices && (
<LayerSelectContainer>
<Select
options={this.state.layerOptions}
onChange={
this.onLayerChange as unknown as (value: unknown) => void
}
value={selectedLayers}
mode="multiple"
/>
</LayerSelectContainer>
)}
<FilterActionsContainer>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={this.props.onClose}
cta
>
{t('Close')}
</Button>
<Button
data-test="adhoc-filter-edit-popover-save-button"
disabled={
!stateIsValid ||
!this.state.isSimpleTabValid ||
!hasUnsavedChanges
}
buttonStyle="primary"
buttonSize="small"
onClick={this.onSave}
cta
>
{t('Save')}
</Button>
<Icons.ArrowsAltOutlined
role="button"
aria-label={t('Resize')}
tabIndex={0}
onMouseDown={this.onDragDown}
className="edit-popover-resize"
return (
<FilterPopoverContentContainer
id="filter-edit-popover"
{...popoverProps}
data-test="filter-edit-popover"
ref={popoverContentRef}
>
<Tabs
id="adhoc-filter-edit-tabs"
defaultActiveKey={adhocFilter.expressionType}
className="adhoc-filter-edit-tabs"
data-test="adhoc-filter-edit-tabs"
style={{ minHeight: height, width }}
allowOverflow
items={[
{
key: ExpressionTypes.Simple,
label: t('Simple'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSimpleTabContent
operators={operators as Operators[] | undefined}
adhocFilter={adhocFilter}
onChange={onAdhocFilterChange}
options={options as ColumnType[]}
datasource={datasource as unknown as Dataset}
onHeightChange={adjustHeight}
partitionColumn={partitionColumn}
popoverRef={popoverContentRef.current}
validHandler={setSimpleTabIsValid}
/>
</ErrorBoundary>
),
},
...(datasource?.type === 'semantic_view'
? []
: [
{
key: ExpressionTypes.Sql,
label: t('Custom SQL'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSqlTabContent
adhocFilter={adhocFilter}
onChange={onAdhocFilterChange}
options={options}
height={height}
datasource={datasource}
/>
</ErrorBoundary>
),
},
]),
]}
/>
{hasDeckSlices && (
<LayerSelectContainer>
<Select
options={layerOptions}
onChange={onLayerChange as unknown as (value: unknown) => void}
value={selectedLayers}
mode="multiple"
/>
</FilterActionsContainer>
</FilterPopoverContentContainer>
);
}
</LayerSelectContainer>
)}
<FilterActionsContainer>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={onClose}
cta
>
{t('Close')}
</Button>
<Button
data-test="adhoc-filter-edit-popover-save-button"
disabled={!stateIsValid || !isSimpleTabValid || !hasUnsavedChanges}
buttonStyle="primary"
buttonSize="small"
onClick={onSave}
cta
>
{t('Save')}
</Button>
<Icons.ArrowsAltOutlined
role="button"
aria-label={t('Resize')}
tabIndex={0}
onMouseDown={onDragDown}
className="edit-popover-resize"
/>
</FilterActionsContainer>
</FilterPopoverContentContainer>
);
}
export default AdhocFilterEditPopover;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, ReactNode } from 'react';
import { memo, useState, useCallback, type ReactNode } from 'react';
import { OptionSortType } from 'src/explore/types';
import AdhocFilterEditPopover from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopover';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
@@ -40,84 +40,81 @@ interface AdhocFilterPopoverTriggerProps {
children?: ReactNode;
}
interface AdhocFilterPopoverTriggerState {
popoverVisible: boolean;
function AdhocFilterPopoverTrigger({
sections,
operators,
adhocFilter,
options,
datasource,
onFilterEdit,
partitionColumn,
isControlledComponent,
visible: propsVisible,
togglePopover: propsTogglePopover,
closePopover: propsClosePopover,
requireSave,
children,
}: AdhocFilterPopoverTriggerProps) {
const [popoverVisible, setPopoverVisible] = useState(false);
const [, forceUpdate] = useState({});
const onPopoverResize = useCallback(() => {
forceUpdate({});
}, []);
const internalClosePopover = useCallback(() => {
setPopoverVisible(false);
}, []);
const internalTogglePopover = useCallback((visible: boolean) => {
setPopoverVisible(visible);
}, []);
const { visible, togglePopover, closePopover } = isControlledComponent
? {
visible: propsVisible,
togglePopover: propsTogglePopover,
closePopover: propsClosePopover,
}
: {
visible: popoverVisible,
togglePopover: internalTogglePopover,
closePopover: internalClosePopover,
};
const overlayContent = (
<ExplorePopoverContent>
<AdhocFilterEditPopover
adhocFilter={adhocFilter}
options={options}
datasource={datasource}
partitionColumn={partitionColumn}
onResize={onPopoverResize}
onClose={closePopover ?? (() => {})}
sections={sections}
operators={operators}
onChange={onFilterEdit}
requireSave={requireSave}
/>
</ExplorePopoverContent>
);
return (
<ControlPopover
trigger="click"
content={overlayContent}
defaultOpen={visible}
open={visible}
onOpenChange={togglePopover}
destroyOnHidden
>
{/* Wrap in span so the Popover can attach a ref without relying
on findDOMNode (deprecated in React 18+). */}
<span>{children}</span>
</ControlPopover>
);
}
class AdhocFilterPopoverTrigger extends PureComponent<
AdhocFilterPopoverTriggerProps,
AdhocFilterPopoverTriggerState
> {
constructor(props: AdhocFilterPopoverTriggerProps) {
super(props);
this.onPopoverResize = this.onPopoverResize.bind(this);
this.closePopover = this.closePopover.bind(this);
this.togglePopover = this.togglePopover.bind(this);
this.state = {
popoverVisible: false,
};
}
onPopoverResize() {
this.forceUpdate();
}
closePopover() {
this.togglePopover(false);
}
togglePopover(visible: boolean) {
this.setState({
popoverVisible: visible,
});
}
render() {
const { adhocFilter, isControlledComponent } = this.props;
const { visible, togglePopover, closePopover } = isControlledComponent
? {
visible: this.props.visible,
togglePopover: this.props.togglePopover,
closePopover: this.props.closePopover,
}
: {
visible: this.state.popoverVisible,
togglePopover: this.togglePopover,
closePopover: this.closePopover,
};
const overlayContent = (
<ExplorePopoverContent>
<AdhocFilterEditPopover
adhocFilter={adhocFilter}
options={this.props.options}
datasource={this.props.datasource}
partitionColumn={this.props.partitionColumn}
onResize={this.onPopoverResize}
onClose={closePopover ?? (() => {})}
sections={this.props.sections}
operators={this.props.operators}
onChange={this.props.onFilterEdit}
requireSave={this.props.requireSave}
/>
</ExplorePopoverContent>
);
return (
<ControlPopover
trigger="click"
content={overlayContent}
defaultOpen={visible}
open={visible}
onOpenChange={togglePopover}
destroyOnHidden
>
{/* Wrap in span so the Popover can attach a ref without relying
on findDOMNode (deprecated in React 18+). */}
<span>{this.props.children}</span>
</ControlPopover>
);
}
}
export default AdhocFilterPopoverTrigger;
// Was a PureComponent before the FC conversion; preserve shallow-equal skip
// (rendered once per chart filter row in the control panel).
export default memo(AdhocFilterPopoverTrigger);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component } from 'react';
import { memo, useState, useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import { Collapse, Label } from '@superset-ui/core/components';
import TextControl from 'src/explore/components/controls/TextControl';
@@ -56,148 +56,162 @@ interface FixedOrMetricControlProps {
isFloat?: boolean;
datasource: DatasourceType;
default?: ControlValue;
}
interface FixedOrMetricControlState {
type: 'fix' | 'metric';
fixedValue: string | number;
metricValue: MetricValue | null;
// ControlHeader props that may be passed through
name?: string;
label?: React.ReactNode;
description?: React.ReactNode;
// Remaining control-header props (validationErrors, hovered, warning,
// renderTrigger, ...) are forwarded to <ControlHeader />, mirroring the
// legacy class component's {...this.props} spread.
[key: string]: unknown;
}
const DEFAULT_VALUE: ControlValue = { type: controlTypes.fixed, value: 5 };
export default class FixedOrMetricControl extends Component<
FixedOrMetricControlProps,
FixedOrMetricControlState
> {
constructor(props: FixedOrMetricControlProps) {
super(props);
this.onChange = this.onChange.bind(this);
this.setType = this.setType.bind(this);
this.setFixedValue = this.setFixedValue.bind(this);
this.setMetric = this.setMetric.bind(this);
const defaultValue = props.default ?? DEFAULT_VALUE;
const type = (props.value?.type ??
defaultValue.type ??
controlTypes.fixed) as 'fix' | 'metric';
const rawValue = props.value?.value ?? defaultValue.value ?? '100';
const fixedValue =
type === controlTypes.fixed && typeof rawValue !== 'object'
? rawValue
: '';
const metricValue =
type === controlTypes.metric && typeof rawValue === 'object'
? (rawValue as MetricValue)
: null;
this.state = {
type,
fixedValue,
metricValue,
};
}
onChange(): void {
this.props.onChange?.({
type: this.state.type,
value:
this.state.type === controlTypes.fixed
? this.state.fixedValue
: (this.state.metricValue ?? undefined),
});
}
setType(type: 'fix' | 'metric'): void {
this.setState({ type }, this.onChange);
}
setFixedValue(fixedValue: string | number): void {
this.setState({ fixedValue }, this.onChange);
}
setMetric(metricValue: MetricValue | null): void {
this.setState({ metricValue }, this.onChange);
}
render() {
const value = this.props.value ?? this.props.default ?? DEFAULT_VALUE;
const type = value?.type ?? controlTypes.fixed;
const columns = this.props.datasource
? this.props.datasource.columns
// Was a PureComponent before the FC conversion; preserve shallow-equal skip.
function FixedOrMetricControl({
onChange = () => {},
value,
datasource,
default: defaultValue = DEFAULT_VALUE,
name,
label,
description,
...restProps
}: FixedOrMetricControlProps) {
const initialType = (value?.type ??
defaultValue?.type ??
controlTypes.fixed) as 'fix' | 'metric';
const initialRawValue = value?.value ?? defaultValue?.value ?? '100';
const initialFixedValue =
initialType === controlTypes.fixed && typeof initialRawValue !== 'object'
? initialRawValue
: '';
const initialMetricValue =
initialType === controlTypes.metric && typeof initialRawValue === 'object'
? (initialRawValue as MetricValue)
: null;
const metrics = this.props.datasource
? this.props.datasource.metrics
: null;
return (
<div>
<ControlHeader {...this.props} />
<Collapse
ghost
items={[
{
key: 'fixed-or-metric',
showArrow: false,
label: (
<Label>
{this.state.type === controlTypes.fixed && (
<span>{this.state.fixedValue}</span>
)}
{this.state.type === controlTypes.metric && (
<span>
<span>{t('metric')}: </span>
<strong>
{this.state.metricValue
? this.state.metricValue.label
: null}
</strong>
</span>
)}
</Label>
),
children: (
<div className="well">
<PopoverSection
title={t('Fixed')}
isSelected={type === controlTypes.fixed}
onSelect={() => {
this.setType(controlTypes.fixed);
const [type, setTypeState] = useState<'fix' | 'metric'>(initialType);
const [fixedValue, setFixedValueState] = useState<string | number>(
initialFixedValue,
);
const [metricValue, setMetricValueState] = useState<MetricValue | null>(
initialMetricValue,
);
const setType = useCallback(
(newType: 'fix' | 'metric') => {
setTypeState(newType);
onChange({
type: newType,
value:
newType === controlTypes.fixed
? fixedValue
: (metricValue ?? undefined),
});
},
[fixedValue, metricValue, onChange],
);
const setFixedValue = useCallback(
(newFixedValue: string | number) => {
setFixedValueState(newFixedValue);
onChange({
type,
value: newFixedValue,
});
},
[type, onChange],
);
const setMetric = useCallback(
(newMetricValue: MetricValue | null) => {
setMetricValueState(newMetricValue);
onChange({
type,
value: newMetricValue ?? undefined,
});
},
[type, onChange],
);
const displayValue = value ?? defaultValue;
const displayType = displayValue?.type ?? controlTypes.fixed;
const columns = datasource ? datasource.columns : null;
const metrics = datasource ? datasource.metrics : null;
return (
<div>
<ControlHeader
{...restProps}
name={name}
label={label}
description={description}
/>
<Collapse
ghost
items={[
{
key: 'fixed-or-metric',
showArrow: false,
label: (
<Label>
{type === controlTypes.fixed && <span>{fixedValue}</span>}
{type === controlTypes.metric && (
<span>
<span>{t('metric')}: </span>
<strong>{metricValue ? metricValue.label : null}</strong>
</span>
)}
</Label>
),
children: (
<div className="well">
<PopoverSection
title={t('Fixed')}
isSelected={displayType === controlTypes.fixed}
onSelect={() => {
setType(controlTypes.fixed);
}}
>
<TextControl
isFloat
onChange={setFixedValue}
onFocus={() => {
setType(controlTypes.fixed);
return {};
}}
>
<TextControl
isFloat
onChange={this.setFixedValue}
onFocus={() => {
this.setType(controlTypes.fixed);
return {};
}}
value={this.state.fixedValue}
/>
</PopoverSection>
<PopoverSection
title={t('Based on a metric')}
isSelected={type === controlTypes.metric}
onSelect={() => {
this.setType(controlTypes.metric);
value={fixedValue}
/>
</PopoverSection>
<PopoverSection
title={t('Based on a metric')}
isSelected={displayType === controlTypes.metric}
onSelect={() => {
setType(controlTypes.metric);
}}
>
<MetricsControl
name="metric"
columns={columns ?? undefined}
savedMetrics={metrics ?? undefined}
multi={false}
onFocus={() => {
setType(controlTypes.metric);
}}
>
<MetricsControl
name="metric"
columns={columns ?? undefined}
savedMetrics={metrics ?? undefined}
multi={false}
onFocus={() => {
this.setType(controlTypes.metric);
}}
onChange={this.setMetric}
value={this.state.metricValue}
datasource={this.props.datasource}
/>
</PopoverSection>
</div>
),
},
]}
/>
</div>
);
}
onChange={setMetric}
value={metricValue}
datasource={datasource}
/>
</PopoverSection>
</div>
),
},
]}
/>
</div>
);
}
export default memo(FixedOrMetricControl);

View File

@@ -224,3 +224,27 @@ export function isDictionaryForAdhocMetric(
export function fromCoreAdhocMetric(metric: CoreAdhocMetric): AdhocMetric {
return new AdhocMetric(metric as AdhocMetricInput);
}
/**
* Metrics are identified by `optionName` when editing, so two metrics sharing
* one (a saved chart can carry duplicate optionNames, e.g. from a duplicated
* metric) would let an edit to one bleed into the other. Given a `seen` set
* shared across a list, return the metric unchanged the first time its
* optionName is encountered, or a copy with a freshly generated optionName on a
* collision, so each metric keeps a unique identity.
*/
export function dedupeAdhocMetricOptionName(
metric: AdhocMetric,
seenOptionNames: Set<string>,
): AdhocMetric {
if (!seenOptionNames.has(metric.optionName)) {
seenOptionNames.add(metric.optionName);
return metric;
}
const deduped = new AdhocMetric({
...(metric as unknown as Record<string, unknown>),
optionName: undefined,
});
seenOptionNames.add(deduped.optionName);
return deduped;
}

View File

@@ -17,7 +17,7 @@
* under the License.
*/
/* eslint-disable camelcase */
import { PureComponent, createRef } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { isDefined, ensureIsArray, DatasourceType } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
@@ -34,6 +34,7 @@ import {
Tooltip,
} from '@superset-ui/core/components';
import sqlKeywords from 'src/SqlLab/utils/sqlKeywords';
import { noOp } from 'src/utils/common';
import {
AGGREGATES_OPTIONS,
POPOVER_INITIAL_HEIGHT,
@@ -48,6 +49,7 @@ import {
} from 'src/explore/components/optionRenderers';
import { getColumnKeywords } from 'src/explore/controlUtils/getColumnKeywords';
import SQLEditorWithValidation from 'src/components/SQLEditorWithValidation';
import type { ExplorePageState } from 'src/explore/types';
import type { RefObject } from 'react';
interface ColumnType {
@@ -94,15 +96,6 @@ interface AdhocMetricEditPopoverProps {
datasource?: DatasourceInfo;
isNewMetric?: boolean;
isLabelModified?: boolean;
/** Names of metrics the user may select; null means no filtering. */
compatibleMetrics?: string[] | null;
}
interface AdhocMetricEditPopoverState {
adhocMetric: AdhocMetric;
savedMetric?: SavedMetricType;
width: number;
height: number;
}
const StyledSelect = styled(Select)`
@@ -119,504 +112,510 @@ const StyledSelect = styled(Select)`
export const SAVED_TAB_KEY = 'SAVED';
class AdhocMetricEditPopover extends PureComponent<
AdhocMetricEditPopoverProps,
AdhocMetricEditPopoverState
> {
// "Saved" is a default tab unless there are no saved metrics for dataset
defaultActiveTabKey = this.getDefaultTab();
function AdhocMetricEditPopover({
onChange,
onClose,
onResize,
getCurrentTab = noOp,
getCurrentLabel,
handleDatasetModal,
adhocMetric: propsAdhocMetric,
columns = [],
savedMetricsOptions,
savedMetric: propsSavedMetric,
datasource,
isNewMetric = false,
isLabelModified,
...popoverProps
}: AdhocMetricEditPopoverProps) {
const [adhocMetric, setAdhocMetric] = useState<AdhocMetric>(propsAdhocMetric);
const [savedMetric, setSavedMetric] = useState<SavedMetricType | undefined>(
propsSavedMetric,
);
const [width, setWidth] = useState(POPOVER_INITIAL_WIDTH);
const [height, setHeight] = useState(POPOVER_INITIAL_HEIGHT);
editorRef: RefObject<editors.EditorHandle>;
const compatibleMetrics = useSelector<
ExplorePageState,
string[] | null | undefined
>(state => state.explore?.compatibleMetrics);
dragStartX = 0;
const aceEditorRef = useRef<editors.EditorHandle>(null);
dragStartY = 0;
const dragStartRef = useRef({
x: 0,
y: 0,
width: 0,
height: 0,
});
dragStartWidth = 0;
dragStartHeight = 0;
constructor(props: AdhocMetricEditPopoverProps) {
super(props);
this.onSave = this.onSave.bind(this);
this.onResetStateAndClose = this.onResetStateAndClose.bind(this);
this.onColumnChange = this.onColumnChange.bind(this);
this.onAggregateChange = this.onAggregateChange.bind(this);
this.onSavedMetricChange = this.onSavedMetricChange.bind(this);
this.onSqlExpressionChange = this.onSqlExpressionChange.bind(this);
this.onDragDown = this.onDragDown.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.onTabChange = this.onTabChange.bind(this);
this.editorRef = createRef();
this.refreshEditor = this.refreshEditor.bind(this);
this.getDefaultTab = this.getDefaultTab.bind(this);
this.state = {
adhocMetric: this.props.adhocMetric,
savedMetric: this.props.savedMetric,
width: POPOVER_INITIAL_WIDTH,
height: POPOVER_INITIAL_HEIGHT,
};
document.addEventListener('mouseup', this.onMouseUp);
}
componentDidMount() {
this.props.getCurrentTab?.(this.defaultActiveTabKey);
}
componentDidUpdate(
_prevProps: AdhocMetricEditPopoverProps,
prevState: AdhocMetricEditPopoverState,
) {
const getDefaultTab = useCallback(() => {
if (
prevState.adhocMetric?.sqlExpression !==
this.state.adhocMetric?.sqlExpression ||
prevState.adhocMetric?.aggregate !== this.state.adhocMetric?.aggregate ||
prevState.adhocMetric?.column?.column_name !==
this.state.adhocMetric?.column?.column_name ||
prevState.savedMetric?.metric_name !== this.state.savedMetric?.metric_name
isDefined(propsAdhocMetric.column) ||
isDefined(propsAdhocMetric.sqlExpression)
) {
this.props.getCurrentLabel?.({
savedMetricLabel:
this.state.savedMetric?.verbose_name ||
this.state.savedMetric?.metric_name,
adhocMetricLabel: this.state.adhocMetric?.getDefaultLabel(),
});
}
}
componentWillUnmount() {
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('mousemove', this.onMouseMove);
}
getDefaultTab() {
const {
adhocMetric,
savedMetric,
savedMetricsOptions,
isNewMetric = false,
} = this.props;
if (isDefined(adhocMetric.column) || isDefined(adhocMetric.sqlExpression)) {
return adhocMetric.expressionType;
return propsAdhocMetric.expressionType;
}
if (
(isNewMetric || savedMetric?.metric_name) &&
(isNewMetric || propsSavedMetric?.metric_name) &&
Array.isArray(savedMetricsOptions) &&
savedMetricsOptions.length > 0
) {
return SAVED_TAB_KEY;
}
return adhocMetric.expressionType;
}
return propsAdhocMetric.expressionType;
}, [propsAdhocMetric, propsSavedMetric, savedMetricsOptions, isNewMetric]);
onSave() {
const { adhocMetric, savedMetric } = this.state;
const defaultActiveTabKey = useMemo(() => getDefaultTab(), [getDefaultTab]);
const onMouseMove = useCallback(
(e: MouseEvent): void => {
onResize();
setWidth(
Math.max(
dragStartRef.current.width + (e.clientX - dragStartRef.current.x),
POPOVER_INITIAL_WIDTH,
),
);
setHeight(
Math.max(
dragStartRef.current.height + (e.clientY - dragStartRef.current.y),
POPOVER_INITIAL_HEIGHT,
),
);
},
[onResize],
);
const onMouseUp = useCallback((): void => {
document.removeEventListener('mousemove', onMouseMove);
}, [onMouseMove]);
useEffect(() => {
getCurrentTab(defaultActiveTabKey);
}, []);
useEffect(() => {
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('mousemove', onMouseMove);
};
}, [onMouseUp, onMouseMove]);
const prevAdhocMetricRef = useRef(adhocMetric);
const prevSavedMetricRef = useRef(savedMetric);
useEffect(() => {
const prevAdhocMetric = prevAdhocMetricRef.current;
const prevSavedMetric = prevSavedMetricRef.current;
if (
prevAdhocMetric?.sqlExpression !== adhocMetric?.sqlExpression ||
prevAdhocMetric?.aggregate !== adhocMetric?.aggregate ||
prevAdhocMetric?.column?.column_name !==
adhocMetric?.column?.column_name ||
prevSavedMetric?.metric_name !== savedMetric?.metric_name
) {
getCurrentLabel?.({
savedMetricLabel: savedMetric?.verbose_name || savedMetric?.metric_name,
adhocMetricLabel: adhocMetric?.getDefaultLabel(),
});
}
prevAdhocMetricRef.current = adhocMetric;
prevSavedMetricRef.current = savedMetric;
}, [adhocMetric, savedMetric, getCurrentLabel]);
const onSave = useCallback(() => {
const metric = savedMetric?.metric_name ? savedMetric : adhocMetric;
const oldMetric = this.props.savedMetric?.metric_name
? this.props.savedMetric
: this.props.adhocMetric;
this.props.onChange(
const oldMetric = propsSavedMetric?.metric_name
? propsSavedMetric
: propsAdhocMetric;
onChange(
{
...metric,
} as Metric,
oldMetric as Metric,
);
this.props.onClose();
}
onClose();
}, [
adhocMetric,
savedMetric,
propsSavedMetric,
propsAdhocMetric,
onChange,
onClose,
]);
onResetStateAndClose() {
this.setState(
{
adhocMetric: this.props.adhocMetric,
savedMetric: this.props.savedMetric,
},
this.props.onClose,
);
}
const onResetStateAndClose = useCallback(() => {
setAdhocMetric(propsAdhocMetric);
setSavedMetric(propsSavedMetric);
onClose();
}, [propsAdhocMetric, propsSavedMetric, onClose]);
onColumnChange(columnName: string): void {
const column = this.props.columns?.find(
column => column.column_name === columnName,
);
this.setState(prevState => ({
adhocMetric: prevState.adhocMetric.duplicateWith({
column,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
savedMetric: undefined,
}));
}
const onColumnChange = useCallback(
(columnName: string): void => {
const column = columns.find(col => col.column_name === columnName);
setAdhocMetric(prevMetric =>
prevMetric.duplicateWith({
column,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
);
setSavedMetric(undefined);
},
[columns],
);
onAggregateChange(aggregate: string | null): void {
// we construct this object explicitly to overwrite the value in the case aggregate is null
this.setState(prevState => ({
adhocMetric: prevState.adhocMetric.duplicateWith({
const onAggregateChange = useCallback((aggregate: string | null): void => {
setAdhocMetric(prevMetric =>
prevMetric.duplicateWith({
aggregate,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
savedMetric: undefined,
}));
}
onSavedMetricChange(savedMetricName: string): void {
const savedMetric = this.props.savedMetricsOptions?.find(
metric => metric.metric_name === savedMetricName,
);
this.setState(prevState => ({
savedMetric,
adhocMetric: prevState.adhocMetric.duplicateWith({
column: undefined,
aggregate: undefined,
sqlExpression: undefined,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
}));
}
setSavedMetric(undefined);
}, []);
onSqlExpressionChange(sqlExpression: string): void {
this.setState(prevState => ({
adhocMetric: prevState.adhocMetric.duplicateWith({
const onSavedMetricChange = useCallback(
(savedMetricName: string): void => {
const metric = savedMetricsOptions?.find(
m => m.metric_name === savedMetricName,
);
setSavedMetric(metric);
setAdhocMetric(prevMetric =>
prevMetric.duplicateWith({
column: undefined,
aggregate: undefined,
sqlExpression: undefined,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
);
},
[savedMetricsOptions],
);
const onSqlExpressionChange = useCallback((sqlExpression: string): void => {
setAdhocMetric(prevMetric =>
prevMetric.duplicateWith({
sqlExpression,
expressionType: EXPRESSION_TYPES.SQL,
}),
savedMetric: undefined,
}));
}
onDragDown(e: React.MouseEvent): void {
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragStartWidth = this.state.width;
this.dragStartHeight = this.state.height;
document.addEventListener('mousemove', this.onMouseMove);
}
onMouseMove(e: MouseEvent): void {
this.props.onResize();
this.setState({
width: Math.max(
this.dragStartWidth + (e.clientX - this.dragStartX),
POPOVER_INITIAL_WIDTH,
),
height: Math.max(
this.dragStartHeight + (e.clientY - this.dragStartY),
POPOVER_INITIAL_HEIGHT,
),
});
}
onMouseUp(): void {
document.removeEventListener('mousemove', this.onMouseMove);
}
onTabChange(tab: string): void {
this.refreshEditor();
this.props.getCurrentTab?.(tab);
}
refreshEditor(): void {
setTimeout(() => {
this.editorRef.current?.resize();
}, 0);
}
renderColumnOption(option: ColumnType): React.ReactNode {
const column = { ...option };
if (
(column as unknown as { metric_name?: string }).metric_name &&
!column.verbose_name
) {
column.verbose_name = (
column as unknown as { metric_name: string }
).metric_name;
}
return <StyledColumnOption column={column} showType />;
}
renderMetricOption(savedMetric: SavedMetricType): React.ReactNode {
return <StyledMetricOption metric={savedMetric} showType />;
}
render() {
const {
adhocMetric: propsAdhocMetric,
savedMetric: propsSavedMetric,
columns,
savedMetricsOptions,
onChange,
onClose,
onResize,
datasource,
isNewMetric = false,
isLabelModified,
...popoverProps
} = this.props;
const { adhocMetric, savedMetric } = this.state;
const columnsArray = columns ?? [];
const keywords = sqlKeywords.concat(
getColumnKeywords(
columnsArray as Parameters<typeof getColumnKeywords>[0],
),
);
setSavedMetric(undefined);
}, []);
const columnValue =
(adhocMetric.column && adhocMetric.column.column_name) ||
adhocMetric.inferSqlExpressionColumn();
const onDragDown = useCallback(
(e: React.MouseEvent): void => {
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
width,
height,
};
document.addEventListener('mousemove', onMouseMove);
},
[width, height, onMouseMove],
);
// autofocus on column if there's no value in column; otherwise autofocus on aggregate
const columnSelectProps = {
const refreshAceEditor = useCallback((): void => {
setTimeout(() => {
aceEditorRef.current?.resize?.();
}, 0);
}, []);
const onTabChange = useCallback(
(tab: string): void => {
refreshAceEditor();
getCurrentTab(tab);
},
[refreshAceEditor, getCurrentTab],
);
const renderColumnOption = useCallback(
(option: ColumnType): React.ReactNode => {
const column = { ...option };
if (
(column as unknown as { metric_name?: string }).metric_name &&
!column.verbose_name
) {
column.verbose_name = (
column as unknown as { metric_name: string }
).metric_name;
}
return <StyledColumnOption column={column} showType />;
},
[],
);
const renderMetricOption = useCallback(
(metric: SavedMetricType): React.ReactNode => (
<StyledMetricOption metric={metric} showType />
),
[],
);
const columnsArray = columns;
const keywords = useMemo(
() =>
sqlKeywords.concat(
getColumnKeywords(
columnsArray as Parameters<typeof getColumnKeywords>[0],
),
),
[columnsArray],
);
const columnValue =
(adhocMetric.column && adhocMetric.column.column_name) ||
adhocMetric.inferSqlExpressionColumn();
const columnSelectProps = useMemo(
() => ({
ariaLabel: t('Select column'),
placeholder: t('%s column(s)', columnsArray.length),
value: columnValue,
onChange: this.onColumnChange,
onChange: onColumnChange,
allowClear: true,
autoFocus: !columnValue,
};
}),
[columnsArray.length, columnValue, onColumnChange],
);
const aggregateSelectProps = {
const aggregateSelectProps = useMemo(
() => ({
ariaLabel: t('Select aggregate options'),
placeholder: t('%s aggregates(s)', AGGREGATES_OPTIONS.length),
value:
adhocMetric.aggregate ??
adhocMetric.inferSqlExpressionAggregate() ??
undefined,
onChange: this.onAggregateChange as (value: unknown) => void,
onChange: onAggregateChange as (value: unknown) => void,
allowClear: true,
autoFocus: !!columnValue,
};
}),
[adhocMetric, columnValue, onAggregateChange],
);
const savedSelectProps = {
const savedSelectProps = useMemo(
() => ({
ariaLabel: t('Select saved metrics'),
placeholder: t('%s saved metric(s)', savedMetricsOptions?.length ?? 0),
value: savedMetric?.metric_name,
onChange: this.onSavedMetricChange,
onChange: onSavedMetricChange,
allowClear: true,
autoFocus: true,
};
}),
[
savedMetricsOptions?.length,
savedMetric?.metric_name,
onSavedMetricChange,
],
);
const stateIsValid = adhocMetric.isValid() || savedMetric?.metric_name;
const hasUnsavedChanges =
isLabelModified ||
isNewMetric ||
!adhocMetric.equals(propsAdhocMetric) ||
(!(
typeof savedMetric?.metric_name === 'undefined' &&
typeof propsSavedMetric?.metric_name === 'undefined'
) &&
savedMetric?.metric_name !== propsSavedMetric?.metric_name);
const stateIsValid = adhocMetric.isValid() || savedMetric?.metric_name;
const hasUnsavedChanges =
isLabelModified ||
isNewMetric ||
!adhocMetric.equals(propsAdhocMetric) ||
(!(
typeof savedMetric?.metric_name === 'undefined' &&
typeof propsSavedMetric?.metric_name === 'undefined'
) &&
savedMetric?.metric_name !== propsSavedMetric?.metric_name);
let extra: ExtraConfig = {};
if (datasource?.extra && typeof datasource.extra === 'string') {
try {
extra = JSON.parse(datasource.extra) as ExtraConfig;
} catch {} // eslint-disable-line no-empty
}
let extra: ExtraConfig = {};
if (datasource?.extra && typeof datasource.extra === 'string') {
try {
extra = JSON.parse(datasource.extra) as ExtraConfig;
} catch {} // eslint-disable-line no-empty
}
return (
<Form
layout="vertical"
id="metrics-edit-popover"
data-test="metrics-edit-popover"
{...popoverProps}
>
<Tabs
id="adhoc-metric-edit-tabs"
data-test="adhoc-metric-edit-tabs"
defaultActiveKey={this.defaultActiveTabKey}
className="adhoc-metric-edit-tabs"
style={{ height: this.state.height, width: this.state.width }}
onChange={this.onTabChange}
allowOverflow
items={[
{
key: SAVED_TAB_KEY,
label: t('Saved'),
children:
ensureIsArray(savedMetricsOptions).length > 0 ? (
<FormItem label={t('Saved metric')}>
<StyledSelect
options={[...ensureIsArray(savedMetricsOptions)]
.sort((a, b) =>
(a.metric_name ?? '').localeCompare(
b.metric_name ?? '',
),
)
.map(savedMetric => ({
value: savedMetric.metric_name,
label: this.renderMetricOption(savedMetric),
key: savedMetric.id,
metric_name: savedMetric.metric_name,
verbose_name: savedMetric.verbose_name ?? '',
disabled:
this.props.compatibleMetrics != null &&
!this.props.compatibleMetrics.includes(
savedMetric.metric_name,
),
}))}
optionFilterProps={['metric_name', 'verbose_name']}
{...savedSelectProps}
/>
</FormItem>
) : datasource?.type === DatasourceType.Table ? (
<EmptyState
image="empty.svg"
size="small"
title={t('No saved metrics found')}
description={t(
'Add metrics to dataset in "Edit datasource" modal',
)}
/>
) : (
<EmptyState
image="empty.svg"
size="small"
title={t('No saved metrics found')}
description={
<>
<span
tabIndex={0}
role="button"
onClick={() => {
this.props.handleDatasetModal?.(true);
this.props.onClose();
}}
>
{t('Create a dataset')}
</span>
{t(' to add metrics')}
</>
}
/>
),
},
{
key: EXPRESSION_TYPES.SIMPLE,
label: extra.disallow_adhoc_metrics ? (
<Tooltip
title={t(
'Simple ad-hoc metrics are not enabled for this dataset',
)}
>
{t('Simple')}
</Tooltip>
) : (
t('Simple')
),
disabled: extra.disallow_adhoc_metrics,
children: (
<>
<FormItem label={t('column')}>
<Select
options={columnsArray.map(column => ({
value: column.column_name,
key: (column as { id?: unknown }).id,
label: this.renderColumnOption(column),
column_name: column.column_name,
verbose_name: column.verbose_name ?? '',
return (
<Form
layout="vertical"
id="metrics-edit-popover"
data-test="metrics-edit-popover"
{...popoverProps}
>
<Tabs
id="adhoc-metric-edit-tabs"
data-test="adhoc-metric-edit-tabs"
defaultActiveKey={defaultActiveTabKey}
className="adhoc-metric-edit-tabs"
style={{ height, width }}
onChange={onTabChange}
allowOverflow
items={[
{
key: SAVED_TAB_KEY,
label: t('Saved'),
children:
ensureIsArray(savedMetricsOptions).length > 0 ? (
<FormItem label={t('Saved metric')}>
<StyledSelect
options={[...ensureIsArray(savedMetricsOptions)]
.sort((a, b) =>
(a.metric_name ?? '').localeCompare(
b.metric_name ?? '',
),
)
.map(metric => ({
value: metric.metric_name,
label: renderMetricOption(metric),
key: metric.id,
metric_name: metric.metric_name,
verbose_name: metric.verbose_name ?? '',
disabled:
compatibleMetrics != null &&
!compatibleMetrics.includes(metric.metric_name),
}))}
optionFilterProps={['column_name', 'verbose_name']}
{...columnSelectProps}
/>
</FormItem>
<FormItem label={t('aggregate')}>
<Select
options={AGGREGATES_OPTIONS.map(option => ({
value: option,
label: option,
key: option,
}))}
{...aggregateSelectProps}
/>
</FormItem>
</>
),
},
{
key: EXPRESSION_TYPES.SQL,
label: extra.disallow_adhoc_metrics ? (
<Tooltip
title={t(
'Custom SQL ad-hoc metrics are not enabled for this dataset',
optionFilterProps={['metric_name', 'verbose_name']}
{...savedSelectProps}
/>
</FormItem>
) : datasource?.type === DatasourceType.Table ? (
<EmptyState
image="empty.svg"
size="small"
title={t('No saved metrics found')}
description={t(
'Add metrics to dataset in "Edit datasource" modal',
)}
>
{t('Custom SQL')}
</Tooltip>
/>
) : (
t('Custom SQL')
),
disabled: extra.disallow_adhoc_metrics,
children: (
<SQLEditorWithValidation
data-test="sql-editor"
ref={this.editorRef}
keywords={keywords}
height={`${this.state.height - 120}px`}
onChange={this.onSqlExpressionChange}
width="100%"
lineNumbers={false}
value={
adhocMetric.sqlExpression ||
adhocMetric.translateToSql({ transformCountDistinct: true })
<EmptyState
image="empty.svg"
size="small"
title={t('No saved metrics found')}
description={
<>
<span
tabIndex={0}
role="button"
onClick={() => {
handleDatasetModal?.(true);
onClose();
}}
>
{t('Create a dataset')}
</span>
{t(' to add metrics')}
</>
}
wordWrap
showValidation
expressionType="metric"
datasourceId={datasource?.id}
datasourceType={datasource?.type}
/>
),
},
]}
},
{
key: EXPRESSION_TYPES.SIMPLE,
label: extra.disallow_adhoc_metrics ? (
<Tooltip
title={t(
'Simple ad-hoc metrics are not enabled for this dataset',
)}
>
{t('Simple')}
</Tooltip>
) : (
t('Simple')
),
disabled: extra.disallow_adhoc_metrics,
children: (
<>
<FormItem label={t('column')}>
<Select
options={columnsArray.map(column => ({
value: column.column_name,
key: (column as { id?: unknown }).id,
label: renderColumnOption(column),
column_name: column.column_name,
verbose_name: column.verbose_name ?? '',
}))}
optionFilterProps={['column_name', 'verbose_name']}
{...columnSelectProps}
/>
</FormItem>
<FormItem label={t('aggregate')}>
<Select
options={AGGREGATES_OPTIONS.map(option => ({
value: option,
label: option,
key: option,
}))}
{...aggregateSelectProps}
/>
</FormItem>
</>
),
},
{
key: EXPRESSION_TYPES.SQL,
label: extra.disallow_adhoc_metrics ? (
<Tooltip
title={t(
'Custom SQL ad-hoc metrics are not enabled for this dataset',
)}
>
{t('Custom SQL')}
</Tooltip>
) : (
t('Custom SQL')
),
disabled: extra.disallow_adhoc_metrics,
children: (
<SQLEditorWithValidation
data-test="sql-editor"
ref={aceEditorRef as RefObject<editors.EditorHandle>}
keywords={keywords}
height={`${height - 120}px`}
onChange={onSqlExpressionChange}
width="100%"
lineNumbers={false}
value={
adhocMetric.sqlExpression ||
adhocMetric.translateToSql({ transformCountDistinct: true })
}
wordWrap
showValidation
expressionType="metric"
datasourceId={datasource?.id}
datasourceType={datasource?.type}
/>
),
},
]}
/>
<div>
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={onResetStateAndClose}
data-test="AdhocMetricEdit#cancel"
cta
>
{t('Close')}
</Button>
<Button
disabled={!stateIsValid || !hasUnsavedChanges}
buttonStyle="primary"
buttonSize="small"
data-test="AdhocMetricEdit#save"
onClick={onSave}
cta
>
{t('Save')}
</Button>
<Icons.ArrowsAltOutlined
role="button"
aria-label={t('Resize')}
tabIndex={0}
onMouseDown={onDragDown}
className="edit-popover-resize"
/>
<div>
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={this.onResetStateAndClose}
data-test="AdhocMetricEdit#cancel"
cta
>
{t('Close')}
</Button>
<Button
disabled={!stateIsValid || !hasUnsavedChanges}
buttonStyle="primary"
buttonSize="small"
data-test="AdhocMetricEdit#save"
onClick={this.onSave}
cta
>
{t('Save')}
</Button>
<Icons.ArrowsAltOutlined
role="button"
aria-label={t('Resize')}
tabIndex={0}
onMouseDown={this.onDragDown}
className="edit-popover-resize"
/>
</div>
</Form>
);
}
}
// ---------------------------------------------------------------------------
// Thin functional wrapper that injects compatibility data from Redux.
// AdhocMetricEditPopover is a class component and cannot use hooks directly.
// ---------------------------------------------------------------------------
function AdhocMetricEditPopoverWithRedux(props: AdhocMetricEditPopoverProps) {
const compatibleMetrics = useSelector(
(state: any) =>
state.explore?.compatibleMetrics as string[] | null | undefined,
);
return (
<AdhocMetricEditPopover {...props} compatibleMetrics={compatibleMetrics} />
</div>
</Form>
);
}
export { AdhocMetricEditPopover };
export default AdhocMetricEditPopoverWithRedux;
export default memo(AdhocMetricEditPopover);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { memo, useCallback } from 'react';
import { Metric } from '@superset-ui/core';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import { DndItemType } from 'src/explore/components/DndItemType';
@@ -42,61 +42,57 @@ interface AdhocMetricOptionProps {
datasourceWarningMessage?: string;
}
class AdhocMetricOption extends PureComponent<AdhocMetricOptionProps> {
constructor(props: AdhocMetricOptionProps) {
super(props);
this.onRemoveMetric = this.onRemoveMetric.bind(this);
}
function AdhocMetricOption({
adhocMetric,
onMetricEdit,
onRemoveMetric,
columns = [],
savedMetricsOptions = [],
savedMetric = {} as SavedMetricTypeDef,
datasource,
onMoveLabel,
onDropLabel,
index = 0,
type = DndItemType.AdhocMetricOption,
multi,
datasourceWarningMessage,
}: AdhocMetricOptionProps) {
const handleRemoveMetric = useCallback(
(e?: React.MouseEvent): void => {
e?.stopPropagation();
onRemoveMetric?.(index);
},
[onRemoveMetric, index],
);
onRemoveMetric(e?: React.MouseEvent): void {
e?.stopPropagation();
this.props.onRemoveMetric?.(this.props.index ?? 0);
}
const withCaret = !(savedMetric as SavedMetricTypeDef).error_text;
render() {
const {
adhocMetric,
onMetricEdit,
columns,
savedMetricsOptions,
savedMetric = {} as SavedMetricTypeDef,
datasource,
onMoveLabel,
onDropLabel,
index,
type,
multi,
datasourceWarningMessage,
} = this.props;
const withCaret = !(savedMetric as SavedMetricTypeDef).error_text;
return (
<AdhocMetricPopoverTrigger
return (
<AdhocMetricPopoverTrigger
adhocMetric={adhocMetric}
onMetricEdit={onMetricEdit}
columns={columns}
savedMetricsOptions={savedMetricsOptions}
savedMetric={savedMetric}
datasource={datasource!}
>
<OptionControlLabel
// eslint-disable-next-line @typescript-eslint/no-explicit-any
savedMetric={savedMetric as any}
adhocMetric={adhocMetric}
onMetricEdit={onMetricEdit}
columns={columns ?? []}
savedMetricsOptions={savedMetricsOptions ?? []}
savedMetric={savedMetric}
datasource={datasource!}
>
<OptionControlLabel
// eslint-disable-next-line @typescript-eslint/no-explicit-any
savedMetric={savedMetric as any}
adhocMetric={adhocMetric}
label={adhocMetric.label}
onRemove={() => this.onRemoveMetric()}
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
index={index ?? 0}
type={type ?? DndItemType.AdhocMetricOption}
withCaret={withCaret}
isFunction
multi={multi}
datasourceWarningMessage={datasourceWarningMessage}
/>
</AdhocMetricPopoverTrigger>
);
}
label={adhocMetric.label}
onRemove={() => handleRemoveMetric()}
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
index={index}
type={type}
withCaret={withCaret}
isFunction
multi={multi}
datasourceWarningMessage={datasourceWarningMessage}
/>
</AdhocMetricPopoverTrigger>
);
}
export default AdhocMetricOption;
export default memo(AdhocMetricOption);

View File

@@ -16,7 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, ReactNode } from 'react';
import {
memo,
ReactNode,
useCallback,
useEffect,
useReducer,
useRef,
useState,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { Metric } from '@superset-ui/core';
import AdhocMetricEditPopoverTitle from 'src/explore/components/controls/MetricControl/AdhocMetricEditPopoverTitle';
@@ -48,239 +56,322 @@ export type AdhocMetricPopoverTriggerProps = {
isNew?: boolean;
};
export type AdhocMetricPopoverTriggerState = {
interface TitleState {
label: string;
hasCustomLabel: boolean;
}
interface ComponentState {
adhocMetric: AdhocMetric;
popoverVisible: boolean;
title: { label: string; hasCustomLabel: boolean };
title: TitleState;
currentLabel: string;
labelModified: boolean;
isTitleEditDisabled: boolean;
showSaveDatasetModal: boolean;
};
}
class AdhocMetricPopoverTrigger extends PureComponent<
AdhocMetricPopoverTriggerProps,
AdhocMetricPopoverTriggerState
> {
constructor(props: AdhocMetricPopoverTriggerProps) {
super(props);
this.onPopoverResize = this.onPopoverResize.bind(this);
this.onLabelChange = this.onLabelChange.bind(this);
this.closePopover = this.closePopover.bind(this);
this.togglePopover = this.togglePopover.bind(this);
this.getCurrentTab = this.getCurrentTab.bind(this);
this.getCurrentLabel = this.getCurrentLabel.bind(this);
this.onChange = this.onChange.bind(this);
this.handleDatasetModal = this.handleDatasetModal.bind(this);
this.state = {
adhocMetric: props.adhocMetric,
popoverVisible: false,
title: {
label: props.adhocMetric.label,
hasCustomLabel: props.adhocMetric.hasCustomLabel,
},
currentLabel: '',
labelModified: false,
isTitleEditDisabled: false,
showSaveDatasetModal: false,
type Action =
| { type: 'SET_ADHOC_METRIC'; payload: AdhocMetric }
| { type: 'SET_POPOVER_VISIBLE'; payload: boolean }
| { type: 'SET_TITLE'; payload: TitleState }
| { type: 'SET_CURRENT_LABEL'; payload: string }
| { type: 'SET_LABEL_MODIFIED'; payload: boolean }
| { type: 'SET_TITLE_EDIT_DISABLED'; payload: boolean }
| { type: 'SET_SHOW_SAVE_DATASET_MODAL'; payload: boolean }
| {
type: 'RESET_ON_OPTION_CHANGE';
payload: { adhocMetric: AdhocMetric; title: TitleState };
}
| { type: 'UPDATE_ADHOC_METRIC'; payload: AdhocMetric }
| { type: 'CLOSE_POPOVER' }
| {
type: 'ON_LABEL_CHANGE';
payload: { label: string; currentLabel: string; fallbackLabel: string };
}
| {
type: 'GET_CURRENT_LABEL';
payload: {
currentLabel: string;
savedMetricLabel: string;
hasCustomLabel: boolean;
};
};
}
static getDerivedStateFromProps(
nextProps: AdhocMetricPopoverTriggerProps,
prevState: AdhocMetricPopoverTriggerState,
) {
if (prevState.adhocMetric.optionName !== nextProps.adhocMetric.optionName) {
function reducer(state: ComponentState, action: Action): ComponentState {
switch (action.type) {
case 'SET_ADHOC_METRIC':
return { ...state, adhocMetric: action.payload };
case 'SET_POPOVER_VISIBLE':
return { ...state, popoverVisible: action.payload };
case 'SET_TITLE':
return { ...state, title: action.payload };
case 'SET_CURRENT_LABEL':
return { ...state, currentLabel: action.payload };
case 'SET_LABEL_MODIFIED':
return { ...state, labelModified: action.payload };
case 'SET_TITLE_EDIT_DISABLED':
return { ...state, isTitleEditDisabled: action.payload };
case 'SET_SHOW_SAVE_DATASET_MODAL':
return { ...state, showSaveDatasetModal: action.payload };
case 'RESET_ON_OPTION_CHANGE':
return {
adhocMetric: nextProps.adhocMetric,
title: {
label: nextProps.adhocMetric.label,
hasCustomLabel: nextProps.adhocMetric.hasCustomLabel,
},
...state,
adhocMetric: action.payload.adhocMetric,
title: action.payload.title,
currentLabel: '',
labelModified: false,
};
}
return {
adhocMetric: nextProps.adhocMetric,
};
}
onLabelChange(e: any) {
const { verbose_name, metric_name } = this.props.savedMetric;
const defaultMetricLabel = this.props.adhocMetric?.getDefaultLabel();
const label = e.target.value;
this.setState(state => ({
title: {
label:
label ||
state.currentLabel ||
verbose_name ||
metric_name ||
defaultMetricLabel,
hasCustomLabel: !!label,
},
labelModified: true,
}));
}
onPopoverResize() {
this.forceUpdate();
}
handleDatasetModal(showModal: boolean) {
this.setState({ showSaveDatasetModal: showModal });
}
closePopover() {
this.togglePopover(false);
this.setState({
labelModified: false,
});
}
togglePopover(visible: boolean) {
this.setState({
popoverVisible: visible,
});
}
getCurrentTab(tab: string) {
this.setState({
isTitleEditDisabled: tab === SAVED_TAB_KEY,
});
}
getCurrentLabel({
savedMetricLabel,
adhocMetricLabel,
}: {
savedMetricLabel: string;
adhocMetricLabel: string;
}) {
const currentLabel = savedMetricLabel || adhocMetricLabel;
this.setState({
currentLabel,
labelModified: true,
});
if (savedMetricLabel || !this.state.title.hasCustomLabel) {
this.setState({
case 'UPDATE_ADHOC_METRIC':
return { ...state, adhocMetric: action.payload };
case 'CLOSE_POPOVER':
return { ...state, popoverVisible: false, labelModified: false };
case 'ON_LABEL_CHANGE': {
const { label, currentLabel, fallbackLabel } = action.payload;
return {
...state,
title: {
label: label || currentLabel || fallbackLabel,
hasCustomLabel: !!label,
},
labelModified: true,
};
}
case 'GET_CURRENT_LABEL': {
const { currentLabel, savedMetricLabel, hasCustomLabel } = action.payload;
const newState: ComponentState = {
...state,
currentLabel,
labelModified: true,
};
if (savedMetricLabel || !hasCustomLabel) {
newState.title = {
label: currentLabel,
hasCustomLabel: false,
},
});
};
}
return newState;
}
}
onChange(newMetric: Metric, oldMetric: Metric) {
this.props.onMetricEdit({ ...newMetric, ...this.state.title }, oldMetric);
}
render() {
const {
adhocMetric,
savedMetric,
columns,
savedMetricsOptions,
datasource,
isControlledComponent,
} = this.props;
const { verbose_name, metric_name } = savedMetric;
const { hasCustomLabel, label } = adhocMetric;
const adhocMetricLabel = hasCustomLabel
? label
: adhocMetric.getDefaultLabel();
const title = this.state.labelModified
? this.state.title
: {
label: verbose_name || metric_name || adhocMetricLabel,
hasCustomLabel,
};
const { visible, togglePopover, closePopover } = isControlledComponent
? {
visible: this.props.visible,
togglePopover: this.props.togglePopover ?? this.togglePopover,
closePopover: this.props.closePopover ?? this.closePopover,
}
: {
visible: this.state.popoverVisible,
togglePopover: this.togglePopover,
closePopover: this.closePopover,
};
const overlayContent = (
<ExplorePopoverContent>
<AdhocMetricEditPopover
adhocMetric={adhocMetric}
columns={columns}
savedMetricsOptions={savedMetricsOptions}
savedMetric={savedMetric as savedMetricType}
datasource={
datasource as unknown as {
type?: string;
id?: number | string;
extra?: string;
}
}
handleDatasetModal={this.handleDatasetModal}
onResize={this.onPopoverResize}
onClose={closePopover}
onChange={
this.onChange as (newMetric: unknown, oldMetric?: unknown) => void
}
getCurrentTab={this.getCurrentTab}
getCurrentLabel={this.getCurrentLabel}
isNewMetric={this.props.isNew}
isLabelModified={
this.state.labelModified &&
adhocMetricLabel !== this.state.title.label
}
/>
</ExplorePopoverContent>
);
const popoverTitle = (
<AdhocMetricEditPopoverTitle
title={title}
onChange={this.onLabelChange}
isEditDisabled={this.state.isTitleEditDisabled}
/>
);
return (
<>
{this.state.showSaveDatasetModal && (
<SaveDatasetModal
visible={this.state.showSaveDatasetModal}
onHide={() => this.handleDatasetModal(false)}
buttonTextOnSave={t('Save')}
buttonTextOnOverwrite={t('Overwrite')}
modalDescription={t(
'Save this query as a virtual dataset to continue exploring',
)}
datasource={datasource}
/>
)}
<ControlPopover
placement="right"
trigger="click"
content={overlayContent}
defaultOpen={visible}
open={visible}
onOpenChange={togglePopover}
title={popoverTitle}
destroyOnHidden
>
{/* Wrap in span so the Popover can attach a ref without relying
on findDOMNode (deprecated in React 18+). */}
<span>{this.props.children}</span>
</ControlPopover>
</>
);
default:
return state;
}
}
export default AdhocMetricPopoverTrigger;
function AdhocMetricPopoverTrigger({
adhocMetric: propsAdhocMetric,
onMetricEdit,
columns,
savedMetricsOptions,
savedMetric,
datasource,
children,
isControlledComponent,
visible: propsVisible,
togglePopover: propsTogglePopover,
closePopover: propsClosePopover,
isNew,
}: AdhocMetricPopoverTriggerProps) {
const initialState: ComponentState = {
adhocMetric: propsAdhocMetric,
popoverVisible: false,
title: {
label: propsAdhocMetric.label,
hasCustomLabel: propsAdhocMetric.hasCustomLabel,
},
currentLabel: '',
labelModified: false,
isTitleEditDisabled: false,
showSaveDatasetModal: false,
};
const [state, dispatch] = useReducer(reducer, initialState);
// Track previous optionName to detect when the metric changes externally
const prevOptionNameRef = useRef(propsAdhocMetric.optionName);
// Handle getDerivedStateFromProps logic
useEffect(() => {
if (prevOptionNameRef.current !== propsAdhocMetric.optionName) {
dispatch({
type: 'RESET_ON_OPTION_CHANGE',
payload: {
adhocMetric: propsAdhocMetric,
title: {
label: propsAdhocMetric.label,
hasCustomLabel: propsAdhocMetric.hasCustomLabel,
},
},
});
} else {
dispatch({ type: 'UPDATE_ADHOC_METRIC', payload: propsAdhocMetric });
}
prevOptionNameRef.current = propsAdhocMetric.optionName;
}, [propsAdhocMetric]);
const [, forceUpdate] = useState({});
const onPopoverResize = useCallback(() => {
forceUpdate({});
}, []);
const onLabelChange = useCallback(
(e: { target: { value: string } }) => {
const { verbose_name, metric_name } = savedMetric;
const defaultMetricLabel = propsAdhocMetric?.getDefaultLabel();
const label = e.target.value;
dispatch({
type: 'ON_LABEL_CHANGE',
payload: {
label,
currentLabel: state.currentLabel,
fallbackLabel: verbose_name || metric_name || defaultMetricLabel,
},
});
},
[savedMetric, propsAdhocMetric, state.currentLabel],
);
const handleDatasetModal = useCallback((showModal: boolean) => {
dispatch({ type: 'SET_SHOW_SAVE_DATASET_MODAL', payload: showModal });
}, []);
const closePopover = useCallback(() => {
dispatch({ type: 'CLOSE_POPOVER' });
}, []);
const togglePopover = useCallback((visible: boolean) => {
dispatch({ type: 'SET_POPOVER_VISIBLE', payload: visible });
}, []);
const getCurrentTab = useCallback((tab: string) => {
dispatch({
type: 'SET_TITLE_EDIT_DISABLED',
payload: tab === SAVED_TAB_KEY,
});
}, []);
const getCurrentLabel = useCallback(
({
savedMetricLabel,
adhocMetricLabel,
}: {
savedMetricLabel: string;
adhocMetricLabel: string;
}) => {
const currentLabel = savedMetricLabel || adhocMetricLabel;
dispatch({
type: 'GET_CURRENT_LABEL',
payload: {
currentLabel,
savedMetricLabel,
hasCustomLabel: state.title.hasCustomLabel,
},
});
},
[state.title.hasCustomLabel],
);
const onChange = useCallback(
(newMetric: Metric, oldMetric: Metric) => {
onMetricEdit({ ...newMetric, ...state.title }, oldMetric);
},
[onMetricEdit, state.title],
);
const { verbose_name, metric_name } = savedMetric;
const { hasCustomLabel, label } = state.adhocMetric;
const adhocMetricLabel = hasCustomLabel
? label
: state.adhocMetric.getDefaultLabel();
const title = state.labelModified
? state.title
: {
label: verbose_name || metric_name || adhocMetricLabel,
hasCustomLabel,
};
const {
visible,
togglePopover: toggle,
closePopover: close,
} = isControlledComponent
? {
visible: propsVisible,
togglePopover: propsTogglePopover ?? togglePopover,
closePopover: propsClosePopover ?? closePopover,
}
: {
visible: state.popoverVisible,
togglePopover,
closePopover,
};
const overlayContent = (
<ExplorePopoverContent>
<AdhocMetricEditPopover
adhocMetric={state.adhocMetric}
columns={columns}
savedMetricsOptions={savedMetricsOptions}
savedMetric={savedMetric as savedMetricType}
datasource={
datasource as unknown as {
type?: string;
id?: number | string;
extra?: string;
}
}
handleDatasetModal={handleDatasetModal}
onResize={onPopoverResize}
onClose={close}
onChange={onChange as (newMetric: unknown, oldMetric?: unknown) => void}
getCurrentTab={getCurrentTab}
getCurrentLabel={getCurrentLabel}
isNewMetric={isNew}
isLabelModified={
state.labelModified && adhocMetricLabel !== state.title.label
}
/>
</ExplorePopoverContent>
);
const popoverTitle = (
<AdhocMetricEditPopoverTitle
title={title}
onChange={onLabelChange}
isEditDisabled={state.isTitleEditDisabled}
/>
);
return (
<>
{state.showSaveDatasetModal && (
<SaveDatasetModal
visible={state.showSaveDatasetModal}
onHide={() => handleDatasetModal(false)}
buttonTextOnSave={t('Save')}
buttonTextOnOverwrite={t('Overwrite')}
modalDescription={t(
'Save this query as a virtual dataset to continue exploring',
)}
datasource={datasource}
/>
)}
<ControlPopover
placement="right"
trigger="click"
content={overlayContent}
defaultOpen={visible}
open={visible}
onOpenChange={toggle}
title={popoverTitle}
destroyOnHidden
>
{/* Wrap in span so the Popover can attach a ref without relying
on findDOMNode (deprecated in React 18+). */}
<span>{children}</span>
</ControlPopover>
</>
);
}
export default memo(AdhocMetricPopoverTrigger);

View File

@@ -124,6 +124,53 @@ test('accepts an edited metric from an AdhocMetricEditPopover', async () => {
]);
});
test('only edits the targeted metric when two metrics share an optionName', async () => {
// A saved chart can carry two adhoc metrics with the same optionName (e.g.
// born from a duplicated metric). Editing one must not overwrite the other.
// Saved charts store metrics as plain dictionaries in form_data, so mirror
// that shape (not AdhocMetric instances) to exercise the real load path.
const sharedOptionName = 'metric_shared_option';
const { onChange } = setup({
value: [
{
expressionType: EXPRESSION_TYPES.SIMPLE,
column: valueColumn,
aggregate: AGGREGATES.SUM,
label: 'SUM(value)',
optionName: sharedOptionName,
},
{
expressionType: EXPRESSION_TYPES.SIMPLE,
column: valueColumn,
aggregate: AGGREGATES.AVG,
label: 'AVG(value)',
optionName: sharedOptionName,
},
],
});
userEvent.click(screen.getByText('SUM(value)'));
await screen.findByText('aggregate');
await selectOption('MAX', 'Select aggregate options');
await screen.findByText('MAX(value)');
userEvent.click(screen.getByRole('button', { name: /save/i }));
// The edit must propagate to the targeted metric (SUM → MAX) while the
// untouched AVG(value) metric stays present and unchanged.
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
aggregate: AGGREGATES.MAX,
label: 'MAX(value)',
}),
expect.objectContaining({
aggregate: AGGREGATES.AVG,
label: 'AVG(value)',
}),
]),
);
});
test('removes metrics if savedMetrics changes', async () => {
setup({
value: [sumValueAdhocMetric],

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { ensureIsArray, usePrevious } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { isEqual } from 'lodash';
@@ -29,7 +29,7 @@ import {
LabelsContainer,
} from 'src/explore/components/controls/OptionControls';
import MetricDefinitionValue from './MetricDefinitionValue';
import AdhocMetric from './AdhocMetric';
import AdhocMetric, { dedupeAdhocMetricOptionName } from './AdhocMetric';
import AdhocMetricPopoverTrigger from './AdhocMetricPopoverTrigger';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -66,10 +66,13 @@ function coerceAdhocMetrics(value: any) {
}
return [value];
}
// Metrics are identified by optionName when editing; regenerate any that
// collide so each keeps a unique identity (see dedupeAdhocMetricOptionName).
const seenOptionNames = new Set<string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return value.map((val: any) => {
if (isDictionaryForAdhocMetric(val)) {
return new AdhocMetric(val);
return dedupeAdhocMetricOptionName(new AdhocMetric(val), seenOptionNames);
}
return val;
});
@@ -344,4 +347,5 @@ const MetricsControl = ({
);
};
export default MetricsControl;
// Was a PureComponent before the FC conversion; preserve shallow-equal skip.
export default memo(MetricsControl);

View File

@@ -16,7 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, type ReactNode } from 'react';
import {
memo,
useState,
useCallback,
useEffect,
useMemo,
useRef,
type ReactNode,
} from 'react';
import { isEqualArray } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { css } from '@apache-superset/core/theme';
@@ -71,26 +79,6 @@ export interface SelectControlProps {
sortComparator?: (a: SelectOption, b: SelectOption) => number;
}
const defaultProps = {
autoFocus: false,
choices: [],
clearable: true,
description: null,
disabled: false,
freeForm: false,
isLoading: false,
label: null,
multi: false,
onChange: () => {},
onFocus: () => {},
showHeader: true,
valueKey: 'value',
};
interface SelectControlState {
options: SelectOption[];
}
const numberComparator = (a: SelectOption, b: SelectOption): number =>
(a.value as number) - (b.value as number);
@@ -139,9 +127,9 @@ export const getSortComparator = (
export const innerGetOptions = (props: SelectControlProps): SelectOption[] => {
const { choices, optionRenderer, valueKey = 'value' } = props;
let options: SelectOption[] = [];
let selectOptions: SelectOption[] = [];
if (props.options) {
options = props.options.map(o => ({
selectOptions = props.options.map(o => ({
...o,
value: o[valueKey] as string | number,
label: optionRenderer
@@ -150,7 +138,7 @@ export const innerGetOptions = (props: SelectControlProps): SelectOption[] => {
}));
} else if (choices) {
// Accepts different formats of input
options = choices.map(c => {
selectOptions = choices.map(c => {
if (Array.isArray(c)) {
const [value, label] = c.length > 1 ? c : [c[0], c[0]];
return {
@@ -162,136 +150,165 @@ export const innerGetOptions = (props: SelectControlProps): SelectOption[] => {
return { value: c as unknown as string | number, label: String(c) };
});
}
return options;
return selectOptions;
};
export default class SelectControl extends PureComponent<
SelectControlProps,
SelectControlState
> {
static defaultProps = defaultProps;
function SelectControl({
ariaLabel,
autoFocus = false,
choices = [],
clearable = true,
description = null,
disabled = false,
freeForm = false,
isLoading = false,
mode,
multi = false,
isMulti,
name,
onChange = () => {},
onFocus = () => {},
onSelect,
onDeselect,
value,
default: defaultValue,
showHeader = true,
optionRenderer,
valueKey = 'value',
options: optionsProp,
placeholder,
filterOption,
tokenSeparators,
notFoundContent,
label = undefined,
renderTrigger,
validationErrors,
rightNode,
leftNode,
onClick,
hovered,
tooltipOnClick,
warning,
danger,
sortComparator,
}: SelectControlProps) {
const [options, setOptions] = useState<SelectOption[]>(() =>
innerGetOptions({
choices,
optionRenderer,
valueKey,
options: optionsProp,
name,
}),
);
constructor(props: SelectControlProps) {
super(props);
this.state = {
options: this.getOptions(props),
};
this.onChange = this.onChange.bind(this);
this.handleFilterOptions = this.handleFilterOptions.bind(this);
}
// Track previous choices/options for comparison
const prevChoicesRef = useRef(choices);
const prevOptionsRef = useRef(optionsProp);
componentDidUpdate(prevProps: SelectControlProps) {
useEffect(() => {
if (
!isEqualArray(this.props.choices, prevProps.choices) ||
!isEqualArray(this.props.options, prevProps.options)
!isEqualArray(choices, prevChoicesRef.current) ||
!isEqualArray(optionsProp, prevOptionsRef.current)
) {
const options = this.getOptions(this.props);
this.setState({ options });
const newOptions = innerGetOptions({
choices,
optionRenderer,
valueKey,
options: optionsProp,
name,
});
setOptions(newOptions);
prevChoicesRef.current = choices;
prevOptionsRef.current = optionsProp;
}
}
}, [choices, optionsProp, optionRenderer, valueKey, name]);
// Beware: This is acting like an on-click instead of an on-change
// (firing every time user chooses vs firing only if a new option is chosen).
onChange(val: SelectValue | SelectOption | SelectOption[]) {
// will eventually call `exploreReducer`: SET_FIELD_VALUE
const { valueKey = 'value' } = this.props;
let onChangeVal: SelectValue = val as SelectValue;
const handleChange = useCallback(
(val: SelectValue | SelectOption | SelectOption[]) => {
// will eventually call `exploreReducer`: SET_FIELD_VALUE
let onChangeVal: SelectValue = val as SelectValue;
if (Array.isArray(val)) {
const values = val.map(v =>
typeof v === 'object' &&
v !== null &&
(v as SelectOption)[valueKey] !== undefined
? (v as SelectOption)[valueKey]
: v,
);
onChangeVal = values as (string | number)[];
}
if (
typeof val === 'object' &&
val !== null &&
!Array.isArray(val) &&
(val as SelectOption)[valueKey] !== undefined
) {
onChangeVal = (val as SelectOption)[valueKey] as string | number;
}
this.props.onChange?.(onChangeVal, []);
}
getOptions(props: SelectControlProps) {
return innerGetOptions(props);
}
handleFilterOptions(text: string, option: SelectOption) {
const { filterOption } = this.props;
return filterOption?.({ data: option }, text) ?? true;
}
render() {
const {
ariaLabel,
autoFocus,
clearable,
disabled,
filterOption,
freeForm,
isLoading,
isMulti,
label,
multi,
name,
notFoundContent,
onFocus,
onSelect,
onDeselect,
placeholder,
showHeader,
tokenSeparators,
value,
// ControlHeader props
description,
renderTrigger,
rightNode,
leftNode,
validationErrors,
onClick,
hovered,
tooltipOnClick,
warning,
danger,
} = this.props;
const headerProps = {
name,
label,
description,
renderTrigger,
rightNode,
leftNode,
validationErrors,
onClick,
hovered,
tooltipOnClick,
warning,
danger,
};
const getValue = () => {
const currentValue =
value ??
(this.props.default !== undefined ? this.props.default : undefined);
// safety check - the value is intended to be undefined but null was used
if (
currentValue === null &&
!this.state.options.some(o => o.value === null)
) {
return undefined;
if (Array.isArray(val)) {
const values = val.map(v =>
typeof v === 'object' &&
v !== null &&
(v as SelectOption)[valueKey] !== undefined
? (v as SelectOption)[valueKey]
: v,
);
onChangeVal = values as (string | number)[];
}
return currentValue;
};
if (
typeof val === 'object' &&
val !== null &&
!Array.isArray(val) &&
(val as SelectOption)[valueKey] !== undefined
) {
onChangeVal = (val as SelectOption)[valueKey] as string | number;
}
onChange?.(onChangeVal, []);
},
[onChange, valueKey],
);
const selectProps = {
const handleFilterOptions = useCallback(
(text: string, option: SelectOption) =>
filterOption?.({ data: option }, text) ?? true,
[filterOption],
);
const headerProps = useMemo(
() => ({
name,
label,
description,
renderTrigger,
rightNode,
leftNode,
validationErrors,
onClick,
hovered,
tooltipOnClick,
warning,
danger,
}),
[
name,
label,
description,
renderTrigger,
rightNode,
leftNode,
validationErrors,
onClick,
hovered,
tooltipOnClick,
warning,
danger,
],
);
const getValue = useCallback(() => {
const currentValue =
value ?? (defaultValue !== undefined ? defaultValue : undefined);
// safety check - the value is intended to be undefined but null was used
if (currentValue === null && !options.some(o => o.value === null)) {
return undefined;
}
return currentValue;
}, [value, defaultValue, options]);
const computedSortComparator = useMemo(
() => getSortComparator(choices, optionsProp, valueKey, sortComparator),
[choices, optionsProp, valueKey, sortComparator],
);
const selectProps = useMemo(
() => ({
allowNewOptions: freeForm,
autoFocus,
ariaLabel:
@@ -300,46 +317,72 @@ export default class SelectControl extends PureComponent<
disabled,
filterOption:
filterOption && typeof filterOption === 'function'
? this.handleFilterOptions
? handleFilterOptions
: true,
header: showHeader && <ControlHeader {...headerProps} />,
loading: isLoading,
mode: this.props.mode || (isMulti || multi ? 'multiple' : 'single'),
mode: mode || (isMulti || multi ? 'multiple' : 'single'),
name: `select-${name}`,
onChange: this.onChange,
onChange: handleChange,
onFocus,
onSelect,
onDeselect,
options: this.state.options,
options,
placeholder,
sortComparator: getSortComparator(
this.props.choices,
this.props.options,
this.props.valueKey,
this.props.sortComparator,
),
sortComparator: computedSortComparator,
value: getValue(),
tokenSeparators,
notFoundContent,
};
}),
[
freeForm,
autoFocus,
ariaLabel,
label,
clearable,
disabled,
filterOption,
handleFilterOptions,
showHeader,
headerProps,
isLoading,
mode,
isMulti,
multi,
name,
handleChange,
onFocus,
onSelect,
onDeselect,
options,
placeholder,
computedSortComparator,
getValue,
tokenSeparators,
notFoundContent,
],
);
return (
<div
css={theme => css`
.type-label {
margin-right: ${theme.sizeUnit * 2}px;
}
.Select__multi-value__label > span,
.Select__option > span,
.Select__single-value > span {
display: flex;
align-items: center;
}
`}
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Select {...(selectProps as any)} />
</div>
);
}
return (
<div
css={theme => css`
.type-label {
margin-right: ${theme.sizeUnit * 2}px;
}
.Select__multi-value__label > span,
.Select__option > span,
.Select__single-value > span {
display: flex;
align-items: center;
}
`}
>
<Select {...(selectProps as Parameters<typeof Select>[0])} />
</div>
);
}
// SelectControl was a PureComponent before the FC conversion; wrap with memo
// to preserve the shallow-equal skip behavior across the many call sites in
// the explore control panel.
export default memo(SelectControl);

View File

@@ -0,0 +1,178 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import SpatialControl from 'src/explore/components/controls/SpatialControl';
jest.mock('src/explore/components/controls/SelectControl', () => ({
__esModule: true,
default: ({
name,
value,
ariaLabel,
}: {
name: string;
value: string;
ariaLabel: string;
}) => (
<div data-test={`select-${name}`} aria-label={ariaLabel}>
{value}
</div>
),
}));
jest.mock('src/explore/components/ControlHeader', () => ({
__esModule: true,
default: () => <div data-test="control-header" />,
}));
const defaultChoices: [string, string][] = [
['longitude', 'longitude'],
['latitude', 'latitude'],
['geo_point', 'geo_point'],
];
test('renders label content showing column names for latlong type', async () => {
const onChange = jest.fn();
render(
<SpatialControl
onChange={onChange}
choices={defaultChoices}
value={{ type: 'latlong', latCol: 'latitude', lonCol: 'longitude' }}
/>,
);
await waitFor(() => {
expect(screen.getByText('longitude | latitude')).toBeInTheDocument();
});
});
test('renders N/A when columns are not set', async () => {
const onChange = jest.fn();
render(<SpatialControl onChange={onChange} choices={[]} />);
await waitFor(() => {
expect(screen.getByText('N/A')).toBeInTheDocument();
});
});
test('calls onChange with latlong value when initialized with choices', async () => {
const onChange = jest.fn();
render(<SpatialControl onChange={onChange} choices={defaultChoices} />);
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
{
type: 'latlong',
latCol: 'longitude',
lonCol: 'longitude',
},
[],
);
});
});
test('calls onChange with errors when no choices are available', async () => {
const onChange = jest.fn();
render(<SpatialControl onChange={onChange} choices={[]} />);
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
{
type: 'latlong',
latCol: undefined,
lonCol: undefined,
},
['Invalid lat/long configuration.'],
);
});
});
test('renders label with lonlatCol for delimited type', async () => {
const onChange = jest.fn();
render(
<SpatialControl
onChange={onChange}
choices={defaultChoices}
value={{ type: 'delimited', lonlatCol: 'geo_point', delimiter: ',' }}
/>,
);
await waitFor(() => {
expect(screen.getByText('geo_point')).toBeInTheDocument();
});
});
test('renders label with geohashCol for geohash type', async () => {
const onChange = jest.fn();
render(
<SpatialControl
onChange={onChange}
choices={defaultChoices}
value={{ type: 'geohash', geohashCol: 'geo_point' }}
/>,
);
await waitFor(() => {
expect(screen.getByText('geo_point')).toBeInTheDocument();
});
});
test('opens popover with three sections when label is clicked', async () => {
const onChange = jest.fn();
render(<SpatialControl onChange={onChange} choices={defaultChoices} />);
const label = await screen.findByText(/longitude/);
await userEvent.click(label);
await waitFor(() => {
expect(
screen.getByText('Longitude & Latitude columns'),
).toBeInTheDocument();
expect(
screen.getByText('Delimited long & lat single column'),
).toBeInTheDocument();
expect(screen.getByText('Geohash')).toBeInTheDocument();
});
});
test('renders ControlHeader', () => {
const onChange = jest.fn();
render(<SpatialControl onChange={onChange} choices={defaultChoices} />);
expect(screen.getByTestId('control-header')).toBeInTheDocument();
});
test('defaults latCol and lonCol to first choice when no value provided', async () => {
const onChange = jest.fn();
render(<SpatialControl onChange={onChange} choices={defaultChoices} />);
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
type: 'latlong',
latCol: 'longitude',
lonCol: 'longitude',
}),
[],
);
});
expect(screen.getByText('longitude | longitude')).toBeInTheDocument();
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component, type ReactNode } from 'react';
import { useState, useCallback, useEffect, type ReactNode } from 'react';
import {
Row,
Col,
@@ -25,7 +25,6 @@ import {
Popover,
} from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation';
import PopoverSection from '@superset-ui/core/components/PopoverSection';
import ControlHeader from '../ControlHeader';
import SelectControl from './SelectControl';
@@ -53,212 +52,229 @@ interface SpatialControlProps {
value?: SpatialValue;
animation?: boolean;
choices?: [string, string][];
// ControlHeader props that may be passed through
name?: string;
label?: React.ReactNode;
description?: React.ReactNode;
// Remaining control-header props (validationErrors, hovered, warning,
// renderTrigger, ...) are forwarded to <ControlHeader />, mirroring the
// legacy class component's {...this.props} spread.
[key: string]: unknown;
}
interface SpatialControlState {
type: SpatialType;
delimiter: string;
latCol: string | undefined;
lonCol: string | undefined;
lonlatCol: string | undefined;
reverseCheckbox: boolean;
geohashCol: string | undefined;
value: SpatialValue | null;
errors: string[];
}
export default function SpatialControl({
onChange = () => {},
value: propValue,
choices = [],
name,
label,
description,
...restProps
}: SpatialControlProps): JSX.Element {
const v = propValue || ({} as SpatialValue);
const defaultCol = choices.length > 0 ? choices[0][0] : undefined;
export default class SpatialControl extends Component<
SpatialControlProps,
SpatialControlState
> {
static defaultProps = {
onChange: () => {},
animation: true,
choices: [],
};
const [type, setTypeState] = useState<SpatialType>(
v.type || spatialTypes.latlong,
);
const [delimiter, setDelimiter] = useState(v.delimiter || ',');
const [latCol, setLatCol] = useState<string | undefined>(
v.latCol || defaultCol,
);
const [lonCol, setLonCol] = useState<string | undefined>(
v.lonCol || defaultCol,
);
const [lonlatCol, setLonlatCol] = useState<string | undefined>(
v.lonlatCol || defaultCol,
);
const [reverseCheckbox, setReverseCheckbox] = useState(
v.reverseCheckbox || false,
);
const [geohashCol, setGeohashCol] = useState<string | undefined>(
v.geohashCol || defaultCol,
);
constructor(props: SpatialControlProps) {
super(props);
const v = props.value || ({} as SpatialValue);
let defaultCol: string | undefined;
if (props.choices && props.choices.length > 0) {
defaultCol = props.choices[0][0];
}
this.state = {
type: v.type || spatialTypes.latlong,
delimiter: v.delimiter || ',',
latCol: v.latCol || defaultCol,
lonCol: v.lonCol || defaultCol,
lonlatCol: v.lonlatCol || defaultCol,
reverseCheckbox: v.reverseCheckbox || false,
geohashCol: v.geohashCol || defaultCol,
value: null,
errors: [],
};
}
componentDidMount(): void {
this.onChange();
}
onChange = (): void => {
const { type } = this.state;
const value: SpatialValue = { type };
const computeValueAndErrors = useCallback((): {
value: SpatialValue;
errors: string[];
} => {
const computedValue: SpatialValue = { type };
const errors: string[] = [];
const errMsg = t('Invalid lat/long configuration.');
if (type === spatialTypes.latlong) {
value.latCol = this.state.latCol;
value.lonCol = this.state.lonCol;
if (!value.lonCol || !value.latCol) {
computedValue.latCol = latCol;
computedValue.lonCol = lonCol;
if (!lonCol || !latCol) {
errors.push(errMsg);
}
} else if (type === spatialTypes.delimited) {
value.lonlatCol = this.state.lonlatCol;
value.delimiter = this.state.delimiter;
value.reverseCheckbox = this.state.reverseCheckbox;
if (!value.lonlatCol || !value.delimiter) {
computedValue.lonlatCol = lonlatCol;
computedValue.delimiter = delimiter;
computedValue.reverseCheckbox = reverseCheckbox;
if (!lonlatCol || !delimiter) {
errors.push(errMsg);
}
} else if (type === spatialTypes.geohash) {
value.geohashCol = this.state.geohashCol;
value.reverseCheckbox = this.state.reverseCheckbox;
if (!value.geohashCol) {
computedValue.geohashCol = geohashCol;
computedValue.reverseCheckbox = reverseCheckbox;
if (!geohashCol) {
errors.push(errMsg);
}
}
this.setState({ value, errors });
this.props.onChange?.(value, errors);
};
setType = (type: SpatialType): void => {
this.setState({ type }, this.onChange);
};
return { value: computedValue, errors };
}, [type, latCol, lonCol, lonlatCol, delimiter, reverseCheckbox, geohashCol]);
toggleCheckbox = (): void => {
this.setState(
prevState => ({ reverseCheckbox: !prevState.reverseCheckbox }),
this.onChange,
);
};
useEffect(() => {
const { value: computedValue, errors } = computeValueAndErrors();
onChange(computedValue, errors);
}, [computeValueAndErrors, onChange]);
renderLabelContent(): string | null {
if (this.state.errors.length > 0) {
const setType = useCallback((newType: SpatialType): void => {
setTypeState(newType);
}, []);
const toggleCheckbox = useCallback((): void => {
setReverseCheckbox(prev => !prev);
}, []);
const { errors } = computeValueAndErrors();
const renderLabelContent = (): string | null => {
if (errors.length > 0) {
return 'N/A';
}
if (this.state.type === spatialTypes.latlong) {
return `${this.state.lonCol} | ${this.state.latCol}`;
if (type === spatialTypes.latlong) {
return `${lonCol} | ${latCol}`;
}
if (this.state.type === spatialTypes.delimited) {
return `${this.state.lonlatCol}`;
if (type === spatialTypes.delimited) {
return `${lonlatCol}`;
}
if (this.state.type === spatialTypes.geohash) {
return `${this.state.geohashCol}`;
if (type === spatialTypes.geohash) {
return `${geohashCol}`;
}
return null;
}
};
const renderSelect = (
name: 'latCol' | 'lonCol' | 'lonlatCol' | 'geohashCol' | 'delimiter',
selectType: SpatialType,
): ReactNode => {
const stateMap: Record<string, string | undefined> = {
latCol,
lonCol,
lonlatCol,
geohashCol,
delimiter,
};
const setterMap: Record<
string,
React.Dispatch<React.SetStateAction<string | undefined>>
> = {
latCol: setLatCol,
lonCol: setLonCol,
lonlatCol: setLonlatCol,
geohashCol: setGeohashCol,
delimiter: setDelimiter as React.Dispatch<
React.SetStateAction<string | undefined>
>,
};
renderSelect(name: keyof SpatialControlState, type: SpatialType): ReactNode {
return (
<SelectControl
ariaLabel={name}
name={name}
choices={this.props.choices}
value={this.state[name] as string}
choices={choices}
value={stateMap[name]}
clearable={false}
onFocus={() => {
this.setType(type);
setType(selectType);
}}
onChange={(value: string) => {
this.setState(
{ [name]: value } as unknown as SpatialControlState,
this.onChange,
);
onChange={(selectValue: string) => {
setterMap[name](selectValue);
}}
/>
);
}
};
renderReverseCheckbox(): ReactNode {
return (
<span>
{t('Reverse lat/long ')}
<Checkbox
checked={this.state.reverseCheckbox}
onChange={this.toggleCheckbox}
/>
</span>
);
}
const renderReverseCheckbox = (): ReactNode => (
<span>
{t('Reverse lat/long ')}
<Checkbox checked={reverseCheckbox} onChange={toggleCheckbox} />
</span>
);
renderPopoverContent(): ReactNode {
return (
<div style={{ width: '300px' }}>
<PopoverSection
title={t('Longitude & Latitude columns')}
isSelected={this.state.type === spatialTypes.latlong}
onSelect={() => this.setType(spatialTypes.latlong)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Longitude')}
{this.renderSelect('lonCol', spatialTypes.latlong)}
</Col>
<Col xs={24} md={12}>
{t('Latitude')}
{this.renderSelect('latCol', spatialTypes.latlong)}
</Col>
</Row>
</PopoverSection>
<PopoverSection
title={t('Delimited long & lat single column')}
info={t(
'Multiple formats accepted, look the geopy.points ' +
'Python library for more details',
)}
isSelected={this.state.type === spatialTypes.delimited}
onSelect={() => this.setType(spatialTypes.delimited)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Column')}
{this.renderSelect('lonlatCol', spatialTypes.delimited)}
</Col>
<Col xs={24} md={12}>
{this.renderReverseCheckbox()}
</Col>
</Row>
</PopoverSection>
<PopoverSection
title={t('Geohash')}
isSelected={this.state.type === spatialTypes.geohash}
onSelect={() => this.setType(spatialTypes.geohash)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Column')}
{this.renderSelect('geohashCol', spatialTypes.geohash)}
</Col>
<Col xs={24} md={12}>
{this.renderReverseCheckbox()}
</Col>
</Row>
</PopoverSection>
</div>
);
}
const renderPopoverContent = (): ReactNode => (
<div style={{ width: '300px' }}>
<PopoverSection
title={t('Longitude & Latitude columns')}
isSelected={type === spatialTypes.latlong}
onSelect={() => setType(spatialTypes.latlong)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Longitude')}
{renderSelect('lonCol', spatialTypes.latlong)}
</Col>
<Col xs={24} md={12}>
{t('Latitude')}
{renderSelect('latCol', spatialTypes.latlong)}
</Col>
</Row>
</PopoverSection>
<PopoverSection
title={t('Delimited long & lat single column')}
info={t(
'Multiple formats accepted, look the geopy.points ' +
'Python library for more details',
)}
isSelected={type === spatialTypes.delimited}
onSelect={() => setType(spatialTypes.delimited)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Column')}
{renderSelect('lonlatCol', spatialTypes.delimited)}
</Col>
<Col xs={24} md={12}>
{renderReverseCheckbox()}
</Col>
</Row>
</PopoverSection>
<PopoverSection
title={t('Geohash')}
isSelected={type === spatialTypes.geohash}
onSelect={() => setType(spatialTypes.geohash)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Column')}
{renderSelect('geohashCol', spatialTypes.geohash)}
</Col>
<Col xs={24} md={12}>
{renderReverseCheckbox()}
</Col>
</Row>
</PopoverSection>
</div>
);
render(): ReactNode {
return (
<div>
<ControlHeader {...this.props} />
<Popover
content={this.renderPopoverContent()}
placement="topLeft"
trigger="click"
>
<Label className="pointer">{this.renderLabelContent()}</Label>
</Popover>
</div>
);
}
return (
<div>
<ControlHeader
{...restProps}
name={name}
label={label}
description={description}
/>
<Popover
content={renderPopoverContent()}
placement="topLeft"
trigger="click"
>
<Label className="pointer">{renderLabelContent()}</Label>
</Popover>
</div>
);
}

View File

@@ -46,7 +46,7 @@ describe('TextArea', () => {
});
test('renders a AceEditor when language is specified', async () => {
const props = { ...defaultProps, language: 'markdown' };
const props = { ...defaultProps, language: 'markdown' as const };
const { container } = render(<TextAreaControl {...props} />);
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
await waitFor(() => {
@@ -55,7 +55,7 @@ describe('TextArea', () => {
});
test('calls onAreaEditorChange when entering in the AceEditor', () => {
const props = { ...defaultProps, language: 'markdown' };
const props = { ...defaultProps, language: 'markdown' as const };
render(<TextAreaControl {...props} />);
const textArea = screen.getByRole('textbox');
fireEvent.change(textArea, { target: { value: 'x' } });

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component } from 'react';
import { useCallback, useEffect, useRef, useMemo } from 'react';
import { debounce } from 'lodash';
import {
Input,
@@ -26,8 +26,7 @@ import {
ModalTrigger,
} from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation';
import { withTheme } from '@apache-superset/core/theme';
import { useTheme } from '@apache-superset/core/theme';
import 'ace-builds/src-min-noconflict/mode-handlebars';
import ControlHeader from 'src/explore/components/ControlHeader';
@@ -35,15 +34,10 @@ import ControlHeader from 'src/explore/components/ControlHeader';
interface HotkeyConfig {
name: string;
key: string;
descr?: string;
func: () => void;
}
interface ThemeType {
colorBorder: string;
colorBgMask: string;
sizeUnit: number;
}
interface TextAreaControlProps {
name?: string;
onChange?: (value: string) => void;
@@ -74,207 +68,221 @@ interface TextAreaControlProps {
tooltipOptions?: Record<string, unknown>;
hotkeys?: HotkeyConfig[];
debounceDelay?: number | null;
theme?: ThemeType;
'aria-required'?: boolean;
value?: string;
[key: string]: unknown;
}
const defaultProps = {
onChange: () => {},
height: 250,
minLines: 3,
maxLines: 10,
offerEditInModal: true,
readOnly: false,
resize: null,
textAreaStyles: {},
tooltipOptions: {},
hotkeys: [],
debounceDelay: null,
};
function TextAreaControl({
name,
onChange = () => {},
initialValue,
height = 250,
minLines = 3,
maxLines = 10,
offerEditInModal = true,
language,
aboveEditorSection,
readOnly = false,
resize = null,
textAreaStyles = {},
tooltipOptions = {},
hotkeys = [],
debounceDelay = null,
'aria-required': ariaRequired,
value,
...restProps
}: TextAreaControlProps) {
const theme = useTheme();
class TextAreaControl extends Component<TextAreaControlProps> {
static defaultProps = defaultProps;
const debouncedOnChangeRef = useRef<ReturnType<
typeof debounce<(value: string) => void>
> | null>(null);
debouncedOnChange:
| ReturnType<typeof debounce<(value: string) => void>>
| undefined;
constructor(props: TextAreaControlProps) {
super(props);
if (props.debounceDelay && props.onChange) {
this.debouncedOnChange = debounce(props.onChange, props.debounceDelay);
}
}
componentDidUpdate(prevProps: TextAreaControlProps) {
if (
this.props.onChange !== prevProps.onChange &&
this.props.debounceDelay &&
this.props.onChange
) {
if (this.debouncedOnChange) {
this.debouncedOnChange.cancel();
// Create or update debounced onChange when dependencies change
useEffect(() => {
if (debounceDelay && onChange) {
if (debouncedOnChangeRef.current) {
debouncedOnChangeRef.current.cancel();
}
this.debouncedOnChange = debounce(
this.props.onChange,
this.props.debounceDelay,
);
}
}
handleChange(value: string | { target: { value: string } }) {
const finalValue = typeof value === 'object' ? value.target.value : value;
if (this.debouncedOnChange) {
this.debouncedOnChange(finalValue);
debouncedOnChangeRef.current = debounce(onChange, debounceDelay);
} else {
this.props.onChange?.(finalValue);
if (debouncedOnChangeRef.current) {
debouncedOnChangeRef.current.cancel();
}
debouncedOnChangeRef.current = null;
}
}
}, [onChange, debounceDelay]);
componentWillUnmount() {
if (this.debouncedOnChange) {
this.debouncedOnChange.flush();
}
}
// Cleanup on unmount — flush pending debounced onChange so last edit isn't lost
useEffect(
() => () => {
if (debouncedOnChangeRef.current) {
debouncedOnChangeRef.current.flush();
}
},
[],
);
renderEditor(inModal = false) {
// Exclude props that shouldn't be passed to TextAreaEditor:
// - theme: TextAreaEditor expects theme as a string, not the theme object from withTheme HOC
// - height: ReactAce expects string, we pass number (height is controlled via minLines/maxLines)
// - other control-specific props and explicitly-set props to avoid duplicate/conflicting assignments
const {
theme,
height,
offerEditInModal,
aboveEditorSection,
resize,
textAreaStyles,
tooltipOptions,
hotkeys,
debounceDelay,
language,
initialValue,
readOnly,
name,
onChange,
value,
minLines: minLinesProp,
maxLines: maxLinesProp,
...editorProps
} = this.props;
const minLines = inModal ? 40 : minLinesProp || 12;
if (language) {
const style: React.CSSProperties = {
border: theme?.colorBorder
? `1px solid ${theme.colorBorder}`
: undefined,
minHeight: `${minLines}em`,
width: 'auto',
...textAreaStyles,
const handleChange = useCallback(
(val: string | { target: { value: string } }) => {
const finalValue = typeof val === 'object' ? val.target.value : val;
if (debouncedOnChangeRef.current) {
debouncedOnChangeRef.current(finalValue);
} else {
onChange?.(finalValue);
}
},
[onChange],
);
const onEditorLoad = useCallback(
(editor: {
commands: {
addCommand: (cmd: {
name: string;
bindKey: { win: string; mac: string };
exec: () => void;
}) => void;
};
if (resize) {
style.resize = resize;
style.overflow = 'auto';
}
if (readOnly) {
style.backgroundColor = theme?.colorBgMask;
}
const onEditorLoad = (editor: {
commands: {
addCommand: (cmd: {
name: string;
bindKey: { win: string; mac: string };
exec: () => void;
}) => void;
};
}) => {
hotkeys?.forEach(keyConfig => {
editor.commands.addCommand({
name: keyConfig.name,
bindKey: { win: keyConfig.key, mac: keyConfig.key },
exec: keyConfig.func,
});
}) => {
hotkeys?.forEach(keyConfig => {
editor.commands.addCommand({
name: keyConfig.name,
bindKey: { win: keyConfig.key, mac: keyConfig.key },
exec: keyConfig.func,
});
};
const codeEditor = (
});
},
[hotkeys],
);
const renderEditor = useCallback(
(inModal = false) => {
const effectiveMinLines = inModal ? 40 : minLines || 12;
if (language) {
const style: React.CSSProperties = {
border: theme?.colorBorder
? `1px solid ${theme.colorBorder}`
: undefined,
minHeight: `${effectiveMinLines}em`,
width: 'auto',
...textAreaStyles,
};
if (resize) {
style.resize = resize;
style.overflow = 'auto';
}
if (readOnly) {
style.backgroundColor = theme?.colorBgMask;
}
const codeEditor = (
<div>
<TextAreaEditor
mode={language}
style={style}
minLines={effectiveMinLines}
maxLines={inModal ? 1000 : maxLines}
editorProps={{ $blockScrolling: true }}
onLoad={onEditorLoad}
defaultValue={initialValue ?? value}
readOnly={readOnly}
key={name}
{...restProps}
onChange={handleChange}
/>
</div>
);
if (tooltipOptions && Object.keys(tooltipOptions).length > 0) {
return <Tooltip {...tooltipOptions}>{codeEditor}</Tooltip>;
}
return codeEditor;
}
const textArea = (
<div>
<TextAreaEditor
mode={language}
style={style}
minLines={minLines}
maxLines={inModal ? 1000 : maxLinesProp}
editorProps={{ $blockScrolling: true }}
onLoad={onEditorLoad}
<Input.TextArea
placeholder={t('textarea')}
onChange={handleChange}
defaultValue={initialValue ?? value}
readOnly={readOnly}
key={name}
{...editorProps}
onChange={this.handleChange.bind(this)}
disabled={readOnly}
style={{ height }}
aria-required={ariaRequired}
/>
</div>
);
if (tooltipOptions) {
return <Tooltip {...tooltipOptions}>{codeEditor}</Tooltip>;
if (tooltipOptions && Object.keys(tooltipOptions).length > 0) {
return <Tooltip {...tooltipOptions}>{textArea}</Tooltip>;
}
return codeEditor;
}
return textArea;
},
[
minLines,
maxLines,
language,
theme,
textAreaStyles,
resize,
readOnly,
onEditorLoad,
initialValue,
value,
name,
restProps,
handleChange,
tooltipOptions,
height,
ariaRequired,
],
);
const textArea = (
<div>
<Input.TextArea
placeholder={t('textarea')}
onChange={this.handleChange.bind(this)}
defaultValue={this.props.initialValue}
disabled={this.props.readOnly}
style={{ height: this.props.height }}
aria-required={this.props['aria-required']}
/>
</div>
);
if (this.props.tooltipOptions) {
return <Tooltip {...this.props.tooltipOptions}>{textArea}</Tooltip>;
}
return textArea;
}
// Pass restProps directly to ControlHeader. The same pattern is used by
// ViewportControl elsewhere in this PR — listing every ControlHeader prop
// explicitly was a literal port of `this.props` access from the class
// version, but for a pure FC `{...restProps}` is equivalent and avoids the
// dep-array drift that a 12-key destructure tends to invite.
const controlHeader = useMemo(
() => <ControlHeader name={name} {...restProps} />,
[name, restProps],
);
renderModalBody() {
return (
const modalBody = useMemo(
() => (
<>
<div>{this.props.aboveEditorSection}</div>
{this.renderEditor(true)}
<div>{aboveEditorSection}</div>
{renderEditor(true)}
</>
);
}
),
[aboveEditorSection, renderEditor],
);
render() {
const controlHeader = <ControlHeader {...this.props} />;
return (
<div>
{controlHeader}
{this.renderEditor()}
{this.props.offerEditInModal && (
<ModalTrigger
// eslint-disable-next-line @typescript-eslint/no-explicit-any
modalTitle={controlHeader as any}
triggerNode={
<Button
buttonSize="small"
style={{ marginTop: this.props.theme?.sizeUnit ?? 4 }}
>
{t('Edit %s in modal', this.props.language)}
</Button>
}
modalBody={this.renderModalBody()}
responsive
/>
)}
</div>
);
}
return (
<div>
{controlHeader}
{renderEditor()}
{offerEditInModal && (
<ModalTrigger
modalTitle={controlHeader}
triggerNode={
<Button
buttonSize="small"
style={{ marginTop: theme?.sizeUnit ?? 4 }}
>
{t('Edit %s in modal', language)}
</Button>
}
modalBody={modalBody}
responsive
/>
)}
</div>
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default withTheme(TextAreaControl as any);
export default TextAreaControl;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component, ChangeEvent } from 'react';
import { useState, useCallback, useRef, useEffect, ChangeEvent } from 'react';
import { legacyValidateNumber, legacyValidateInteger } from '@superset-ui/core';
import { debounce } from 'lodash';
import ControlHeader from 'src/explore/components/ControlHeader';
@@ -31,8 +31,8 @@ export interface TextControlProps<T extends InputValueType = InputValueType> {
disabled?: boolean;
isFloat?: boolean;
isInt?: boolean;
onChange?: (value: T, errors: any) => void;
onFocus?: () => {};
onChange?: (value: T, errors: string[]) => void;
onFocus?: () => void;
placeholder?: string;
value?: T | null;
controlId?: string;
@@ -42,82 +42,111 @@ export interface TextControlProps<T extends InputValueType = InputValueType> {
showHeader?: boolean;
}
export interface TextControlState {
value: string;
}
const safeStringify = (value?: InputValueType | null) =>
value == null ? '' : String(value);
export default class TextControl<
T extends InputValueType = InputValueType,
> extends Component<TextControlProps<T>, TextControlState> {
initialValue?: TextControlProps['value'];
function TextControl<T extends InputValueType = InputValueType>({
name,
label,
description,
disabled,
isFloat,
isInt,
onChange,
onFocus,
placeholder,
value,
controlId,
renderTrigger,
validationErrors,
hovered,
showHeader,
}: TextControlProps<T>) {
const [localValue, setLocalValue] = useState<string>(safeStringify(value));
const prevValueRef = useRef<T | null | undefined>(value);
constructor(props: TextControlProps<T>) {
super(props);
this.initialValue = props.value;
this.state = {
value: safeStringify(this.initialValue),
};
const handleChange = useCallback(
(inputValue: string) => {
let parsedValue: InputValueType = inputValue;
const errors: string[] = [];
if (inputValue !== '' && isFloat) {
const error = legacyValidateNumber(inputValue);
if (error) {
errors.push(error);
} else {
parsedValue = inputValue.match(/.*([.0])$/g)
? inputValue
: parseFloat(inputValue);
}
}
if (inputValue !== '' && isInt) {
const error = legacyValidateInteger(inputValue);
if (error) {
errors.push(error);
} else {
parsedValue = parseInt(inputValue, 10);
}
}
onChange?.(parsedValue as T, errors);
},
[isFloat, isInt, onChange],
);
const debouncedOnChangeRef = useRef(
debounce((inputValue: string, changeFn: (val: string) => void) => {
changeFn(inputValue);
}, Constants.FAST_DEBOUNCE),
);
useEffect(
() => () => {
debouncedOnChangeRef.current.cancel();
},
[],
);
const onChangeWrapper = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const { value: newValue } = event.target;
setLocalValue(newValue);
debouncedOnChangeRef.current(newValue, handleChange);
},
[handleChange],
);
// Sync local value when prop value changes externally
let displayValue = localValue;
if (safeStringify(prevValueRef.current) !== safeStringify(value)) {
prevValueRef.current = value;
displayValue = safeStringify(value);
}
onChange = (inputValue: string) => {
let parsedValue: InputValueType = inputValue;
// Validation & casting
const errors = [];
if (inputValue !== '' && this.props.isFloat) {
const error = legacyValidateNumber(inputValue);
if (error) {
errors.push(error);
} else {
parsedValue = inputValue.match(/.*([.0])$/g)
? inputValue
: parseFloat(inputValue);
}
}
if (inputValue !== '' && this.props.isInt) {
const error = legacyValidateInteger(inputValue);
if (error) {
errors.push(error);
} else {
parsedValue = parseInt(inputValue, 10);
}
}
this.props.onChange?.(parsedValue as T, errors);
};
debouncedOnChange = debounce((inputValue: string) => {
this.onChange(inputValue);
}, Constants.FAST_DEBOUNCE);
onChangeWrapper = (event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
this.setState({ value }, () => {
this.debouncedOnChange(value);
});
};
render() {
let { value } = this.state;
if (this.initialValue !== this.props.value) {
this.initialValue = this.props.value;
value = safeStringify(this.props.value);
}
return (
<div>
<ControlHeader {...this.props} />
<Input
type="text"
data-test="inline-name"
placeholder={this.props.placeholder}
onChange={this.onChangeWrapper}
onFocus={this.props.onFocus}
value={value}
disabled={this.props.disabled}
aria-label={this.props.label}
/>
</div>
);
}
// Note: controlId and showHeader props are not used by ControlHeader
return (
<div>
<ControlHeader
name={name}
label={label}
description={description}
renderTrigger={renderTrigger}
validationErrors={validationErrors}
hovered={hovered}
/>
<Input
type="text"
data-test="inline-name"
placeholder={placeholder}
onChange={onChangeWrapper}
onFocus={onFocus}
value={displayValue}
disabled={disabled}
aria-label={label}
/>
</div>
);
}
export default TextControl;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component } from 'react';
import React, { useCallback, useState } from 'react';
import {
Button,
Col,
@@ -69,23 +69,6 @@ interface TimeSeriesColumnControlState {
popoverVisible: boolean;
}
const defaultProps = {
label: t('Time series columns'),
tooltip: '',
colType: '',
width: '',
height: '',
timeLag: '',
timeRatio: '',
comparisonType: '',
showYAxis: false,
yAxisBounds: [null, null],
bounds: [null, null],
d3format: '',
dateFormat: '',
sparkType: 'line',
};
const comparisonTypeOptions = [
{ value: 'value', label: t('Actual value'), key: 'value' },
{ value: 'diff', label: t('Difference'), key: 'diff' },
@@ -128,97 +111,118 @@ const ButtonBar = styled.div`
justify-content: center;
`;
export default class TimeSeriesColumnControl extends Component<
TimeSeriesColumnControlProps,
TimeSeriesColumnControlState
> {
static defaultProps = defaultProps;
constructor(props: TimeSeriesColumnControlProps) {
super(props);
this.onSave = this.onSave.bind(this);
this.onClose = this.onClose.bind(this);
this.resetState = this.resetState.bind(this);
this.initialState = this.initialState.bind(this);
this.onPopoverVisibleChange = this.onPopoverVisibleChange.bind(this);
this.state = this.initialState();
}
initialState(): TimeSeriesColumnControlState {
return {
label: this.props.label ?? t('Time series columns'),
tooltip: this.props.tooltip ?? '',
colType: this.props.colType ?? '',
width: this.props.width ?? '',
height: this.props.height ?? '',
timeLag: this.props.timeLag ?? 0,
timeRatio: this.props.timeRatio ?? '',
comparisonType: this.props.comparisonType ?? '',
showYAxis: this.props.showYAxis ?? false,
yAxisBounds: this.props.yAxisBounds ?? [null, null],
bounds: this.props.bounds ?? [null, null],
d3format: this.props.d3format ?? '',
dateFormat: this.props.dateFormat ?? '',
sparkType: this.props.sparkType ?? 'line',
function TimeSeriesColumnControl({
label: propLabel = t('Time series columns'),
tooltip: propTooltip = '',
colType: propColType = '',
width: propWidth = '',
height: propHeight = '',
timeLag: propTimeLag = '',
timeRatio: propTimeRatio = '',
comparisonType: propComparisonType = '',
showYAxis: propShowYAxis = false,
yAxisBounds: propYAxisBounds = [null, null],
bounds: propBounds = [null, null],
d3format: propD3format = '',
dateFormat: propDateFormat = '',
sparkType: propSparkType = 'line',
onChange,
}: TimeSeriesColumnControlProps) {
const getInitialState = useCallback(
(): TimeSeriesColumnControlState => ({
label: propLabel ?? t('Time series columns'),
tooltip: propTooltip ?? '',
colType: propColType ?? '',
width: propWidth ?? '',
height: propHeight ?? '',
timeLag: propTimeLag ?? 0,
timeRatio: propTimeRatio ?? '',
comparisonType: propComparisonType ?? '',
showYAxis: propShowYAxis ?? false,
yAxisBounds: propYAxisBounds ?? [null, null],
bounds: propBounds ?? [null, null],
d3format: propD3format ?? '',
dateFormat: propDateFormat ?? '',
sparkType: propSparkType ?? 'line',
popoverVisible: false,
};
}
}),
[
propLabel,
propTooltip,
propColType,
propWidth,
propHeight,
propTimeLag,
propTimeRatio,
propComparisonType,
propShowYAxis,
propYAxisBounds,
propBounds,
propD3format,
propDateFormat,
propSparkType,
],
);
resetState() {
const initialState = this.initialState();
this.setState({ ...initialState });
}
const [state, setState] =
useState<TimeSeriesColumnControlState>(getInitialState());
onSave() {
this.props.onChange?.(this.state);
this.setState({ popoverVisible: false });
}
const resetState = useCallback(() => {
setState(getInitialState());
}, [getInitialState]);
onClose() {
this.resetState();
}
const onSave = useCallback(() => {
onChange?.(state);
setState(prev => ({ ...prev, popoverVisible: false }));
}, [onChange, state]);
onSelectChange(attr: string, opt: string) {
this.setState(prevState => ({ ...prevState, [attr]: opt }));
}
const onClose = useCallback(() => {
resetState();
}, [resetState]);
onTextInputChange(attr: string, event: React.ChangeEvent<HTMLInputElement>) {
this.setState(prevState => ({ ...prevState, [attr]: event.target.value }));
}
const onSelectChange = useCallback((attr: string, opt: string) => {
setState(prev => ({ ...prev, [attr]: opt }));
}, []);
onCheckboxChange(attr: string, value: boolean) {
this.setState(prevState => ({ ...prevState, [attr]: value }));
}
const onTextInputChange = useCallback(
(attr: string, event: React.ChangeEvent<HTMLInputElement>) => {
setState(prev => ({ ...prev, [attr]: event.target.value }));
},
[],
);
onBoundsChange(bounds: (number | null)[]) {
this.setState({ bounds });
}
const onCheckboxChange = useCallback((attr: string, value: boolean) => {
setState(prev => ({ ...prev, [attr]: value }));
}, []);
onPopoverVisibleChange(popoverVisible: boolean) {
if (popoverVisible) {
this.setState({ popoverVisible });
} else {
this.resetState();
}
}
const onBoundsChange = useCallback((bounds: (number | null)[]) => {
setState(prev => ({ ...prev, bounds }));
}, []);
onYAxisBoundsChange(yAxisBounds: (number | null)[]) {
this.setState({ yAxisBounds });
}
const onPopoverVisibleChange = useCallback(
(popoverVisible: boolean) => {
if (popoverVisible) {
setState(prev => ({ ...prev, popoverVisible }));
} else {
resetState();
}
},
[resetState],
);
textSummary() {
return `${this.props.label ?? ''}`;
}
const onYAxisBoundsChange = useCallback((yAxisBounds: (number | null)[]) => {
setState(prev => ({ ...prev, yAxisBounds }));
}, []);
formRow(
label: string,
tooltip: string,
ttLabel: string,
control: React.ReactNode,
) {
return (
const textSummary = useCallback(() => `${propLabel ?? ''}`, [propLabel]);
const formRow = useCallback(
(
label: string,
tooltip: string,
ttLabel: string,
control: React.ReactNode,
) => (
<StyledRow>
<StyledCol xs={24} md={11}>
{label}
@@ -228,214 +232,241 @@ export default class TimeSeriesColumnControl extends Component<
{control}
</Col>
</StyledRow>
);
}
),
[],
);
const renderPopover = useCallback(() => {
const handleLabelChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('label', e);
const handleTooltipChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('tooltip', e);
const handleColTypeChange = (opt: string) => onSelectChange('colType', opt);
const handleSparkTypeChange = (opt: string) =>
onSelectChange('sparkType', opt);
const handleWidthChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('width', e);
const handleHeightChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('height', e);
const handleTimeLagChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('timeLag', e);
const handleTimeRatioChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('timeRatio', e);
const handleComparisonTypeChange = (opt: string) =>
onSelectChange('comparisonType', opt);
const handleShowYAxisChange = (value: boolean) =>
onCheckboxChange('showYAxis', value);
const handleD3formatChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('d3format', e);
const handleDateFormatChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('dateFormat', e);
renderPopover() {
return (
<div id="ts-col-popo" style={{ width: 320 }}>
{this.formRow(
{formRow(
t('Label'),
t('The column header label'),
'time-lag',
<Input
value={this.state.label}
onChange={this.onTextInputChange.bind(this, 'label')}
value={state.label}
onChange={handleLabelChange}
placeholder={t('Label')}
/>,
)}
{this.formRow(
{formRow(
t('Tooltip'),
t('Column header tooltip'),
'col-tooltip',
<Input
value={this.state.tooltip}
onChange={this.onTextInputChange.bind(this, 'tooltip')}
value={state.tooltip}
onChange={handleTooltipChange}
placeholder={t('Tooltip')}
/>,
)}
{this.formRow(
{formRow(
t('Type'),
t('Type of comparison, value difference or percentage'),
'col-type',
<Select
ariaLabel={t('Type')}
value={this.state.colType || undefined}
onChange={this.onSelectChange.bind(this, 'colType')}
value={state.colType || undefined}
onChange={handleColTypeChange}
options={colTypeOptions}
/>,
)}
<Divider />
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Chart type'),
t('Type of chart to display in sparkline'),
'spark-type',
<Select
ariaLabel={t('Chart Type')}
value={this.state.sparkType || undefined}
onChange={this.onSelectChange.bind(this, 'sparkType')}
value={state.sparkType || undefined}
onChange={handleSparkTypeChange}
options={sparkTypeOptions}
/>,
)}
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Width'),
t('Width of the sparkline'),
'spark-width',
<Input
value={this.state.width}
onChange={this.onTextInputChange.bind(this, 'width')}
value={state.width}
onChange={handleWidthChange}
placeholder={t('Width')}
/>,
)}
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Height'),
t('Height of the sparkline'),
'spark-width',
<Input
value={this.state.height}
onChange={this.onTextInputChange.bind(this, 'height')}
value={state.height}
onChange={handleHeightChange}
placeholder={t('Height')}
/>,
)}
{['time', 'avg'].indexOf(this.state.colType) >= 0 &&
this.formRow(
{['time', 'avg'].indexOf(state.colType) >= 0 &&
formRow(
t('Time lag'),
t(
'Number of periods to compare against. You can use negative numbers to compare from the beginning of the time range.',
),
'time-lag',
<Input
value={this.state.timeLag}
onChange={this.onTextInputChange.bind(this, 'timeLag')}
value={state.timeLag}
onChange={handleTimeLagChange}
placeholder={t('Time Lag')}
/>,
)}
{['spark'].indexOf(this.state.colType) >= 0 &&
this.formRow(
{['spark'].indexOf(state.colType) >= 0 &&
formRow(
t('Time ratio'),
t('Number of periods to ratio against'),
'time-ratio',
<Input
value={this.state.timeRatio}
onChange={this.onTextInputChange.bind(this, 'timeRatio')}
value={state.timeRatio}
onChange={handleTimeRatioChange}
placeholder={t('Time Ratio')}
/>,
)}
{this.state.colType === 'time' &&
this.formRow(
{state.colType === 'time' &&
formRow(
t('Type'),
t('Type of comparison, value difference or percentage'),
'comp-type',
<Select
ariaLabel={t('Type')}
value={this.state.comparisonType || undefined}
onChange={this.onSelectChange.bind(this, 'comparisonType')}
value={state.comparisonType || undefined}
onChange={handleComparisonTypeChange}
options={comparisonTypeOptions}
/>,
)}
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Show Y-axis'),
t(
'Show Y-axis on the sparkline. Will display the manually set min/max if set or min/max values in the data otherwise.',
),
'show-y-axis-bounds',
<CheckboxControl
value={this.state.showYAxis}
onChange={this.onCheckboxChange.bind(this, 'showYAxis')}
value={state.showYAxis}
onChange={handleShowYAxisChange}
/>,
)}
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Y-axis bounds'),
t('Manually set min/max values for the y-axis.'),
'y-axis-bounds',
<BoundsControl
value={this.state.yAxisBounds}
onChange={this.onYAxisBoundsChange.bind(this)}
value={state.yAxisBounds}
onChange={onYAxisBoundsChange}
/>,
)}
{this.state.colType !== 'spark' &&
this.formRow(
{state.colType !== 'spark' &&
formRow(
t('Color bounds'),
t(`Number bounds used for color encoding from red to blue.
Reverse the numbers for blue to red. To get pure red or blue,
you can enter either only min or max.`),
'bounds',
<BoundsControl
value={this.state.bounds}
onChange={this.onBoundsChange.bind(this)}
/>,
<BoundsControl value={state.bounds} onChange={onBoundsChange} />,
)}
{this.formRow(
{formRow(
t('Number format'),
t('Optional d3 number format string'),
'd3-format',
<Input
value={this.state.d3format}
onChange={this.onTextInputChange.bind(this, 'd3format')}
value={state.d3format}
onChange={handleD3formatChange}
placeholder={t('Number format string')}
/>,
)}
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Date format'),
t('Optional d3 date format string'),
'date-format',
<Input
value={this.state.dateFormat}
onChange={this.onTextInputChange.bind(this, 'dateFormat')}
value={state.dateFormat}
onChange={handleDateFormatChange}
placeholder={t('Date format string')}
/>,
)}
<ButtonBar>
<Button buttonSize="small" onClick={this.onClose} cta>
<Button buttonSize="small" onClick={onClose} cta>
{t('Close')}
</Button>
<Button
buttonStyle="primary"
buttonSize="small"
onClick={this.onSave}
cta
>
<Button buttonStyle="primary" buttonSize="small" onClick={onSave} cta>
{t('Save')}
</Button>
</ButtonBar>
</div>
);
}
}, [
state,
formRow,
onTextInputChange,
onSelectChange,
onCheckboxChange,
onBoundsChange,
onYAxisBoundsChange,
onClose,
onSave,
]);
render() {
return (
<span>
{this.textSummary()}{' '}
<ControlPopover
trigger="click"
content={this.renderPopover()}
title={t('Column Configuration')}
open={this.state.popoverVisible}
onOpenChange={this.onPopoverVisibleChange}
return (
<span>
{textSummary()}{' '}
<ControlPopover
trigger="click"
content={renderPopover()}
title={t('Column Configuration')}
open={state.popoverVisible}
onOpenChange={onPopoverVisibleChange}
>
<span
css={theme => ({
display: 'inline-block',
cursor: 'pointer',
'& svg path': {
fill: theme.colorIcon,
transition: `fill ${theme.motionDurationMid} ease-out`,
},
'&:hover svg path': {
fill: theme.colorPrimary,
},
})}
>
<span
css={theme => ({
display: 'inline-block',
cursor: 'pointer',
'& svg path': {
fill: theme.colorIcon,
transition: `fill ${theme.motionDurationMid} ease-out`,
},
'&:hover svg path': {
fill: theme.colorPrimary,
},
})}
>
<Icons.EditOutlined iconSize="s" />
</span>
</ControlPopover>
</span>
);
}
<Icons.EditOutlined iconSize="s" />
</span>
</ControlPopover>
</span>
);
}
export default TimeSeriesColumnControl;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component, type ReactNode } from 'react';
import { useCallback, type ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import { Popover, FormLabel, Label } from '@superset-ui/core/components';
import { decimalToSexagesimal } from 'geolib';
@@ -55,63 +55,57 @@ interface ViewportControlProps {
name: string;
}
export default class ViewportControl extends Component<ViewportControlProps> {
static defaultProps = {
onChange: () => {},
default: { type: 'fix', value: 5 },
value: DEFAULT_VIEWPORT,
};
export default function ViewportControl({
onChange = () => {},
value = DEFAULT_VIEWPORT,
name,
...restProps
}: ViewportControlProps): JSX.Element {
const handleChange = useCallback(
(ctrl: keyof Viewport, ctrlValue: number): void => {
onChange({
...value,
[ctrl]: ctrlValue,
});
},
[onChange, value],
);
onChange = (ctrl: keyof Viewport, value: number): void => {
this.props.onChange?.({
...this.props.value!,
[ctrl]: value,
});
};
const renderTextControl = (ctrl: keyof Viewport): ReactNode => (
<div key={ctrl}>
<FormLabel>{ctrl}</FormLabel>
<TextControl
value={value?.[ctrl]}
onChange={(ctrlValue: number) => handleChange(ctrl, ctrlValue)}
isFloat
/>
</div>
);
renderTextControl(ctrl: keyof Viewport): ReactNode {
return (
<div key={ctrl}>
<FormLabel>{ctrl}</FormLabel>
<TextControl
value={this.props.value?.[ctrl]}
onChange={(value: number) => this.onChange(ctrl, value)}
isFloat
/>
</div>
);
}
const renderPopover = (): ReactNode => (
<div id={`filter-popover-${name}`}>
{PARAMS.map(ctrl => renderTextControl(ctrl))}
</div>
);
renderPopover(): ReactNode {
return (
<div id={`filter-popover-${this.props.name}`}>
{PARAMS.map(ctrl => this.renderTextControl(ctrl))}
</div>
);
}
renderLabel(): string {
if (this.props.value?.longitude && this.props.value?.latitude) {
return `${decimalToSexagesimal(
this.props.value.longitude,
)} | ${decimalToSexagesimal(this.props.value.latitude)}`;
const renderLabel = (): string => {
if (value?.longitude && value?.latitude) {
return `${decimalToSexagesimal(value.longitude)} | ${decimalToSexagesimal(value.latitude)}`;
}
return 'N/A';
}
};
render(): ReactNode {
return (
<div>
<ControlHeader {...this.props} />
<Popover
trigger="click"
placement="right"
content={this.renderPopover()}
title={t('Viewport')}
>
<Label className="pointer">{this.renderLabel()}</Label>
</Popover>
</div>
);
}
return (
<div>
<ControlHeader {...restProps} name={name} />
<Popover
trigger="click"
placement="right"
content={renderPopover()}
title={t('Viewport')}
>
<Label className="pointer">{renderLabel()}</Label>
</Popover>
</div>
);
}

View File

@@ -16,7 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, FC, PureComponent, useMemo } from 'react';
import {
useState,
useEffect,
FC,
useMemo,
ReactNode,
Component,
ErrorInfo,
} from 'react';
import rison from 'rison';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
@@ -134,6 +142,7 @@ const RightMenu = ({
EXCEL_EXTENSIONS,
ALLOWED_EXTENSIONS,
HAS_GSHEETS_INSTALLED,
SCARF_ANALYTICS,
} = useSelector<any, ExtensionConfigs>(state => state.common.conf);
const [showDatabaseModal, setShowDatabaseModal] = useState<boolean>(false);
const [showCSVUploadModal, setShowCSVUploadModal] = useState<boolean>(false);
@@ -550,11 +559,11 @@ const RightMenu = ({
style: { height: 'auto', minHeight: 'auto' },
label: (
<div
css={(theme: SupersetTheme) => css`
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextSecondary || theme.colorText};
css={(themeArg: SupersetTheme) => css`
font-size: ${themeArg.fontSizeSM}px;
color: ${themeArg.colorTextSecondary || themeArg.colorText};
white-space: pre-wrap;
padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
padding: ${themeArg.sizeUnit}px ${themeArg.sizeUnit * 2}px;
`}
>
{[
@@ -781,6 +790,7 @@ const RightMenu = ({
// segments in the Scarf pixel URL.
sha={navbarRight.version_sha || undefined}
build={navbarRight.build_number || undefined}
enabled={SCARF_ANALYTICS !== false}
/>
</StyledDiv>
);
@@ -800,23 +810,39 @@ const RightMenuWithQueryWrapper: FC<RightMenuProps> = props => {
// Superset still has multiple entry points, and not all of them have
// the same setup, and critically, not all of them have the QueryParamProvider.
// This wrapper ensures the RightMenu renders regardless of the provider being present.
class RightMenuErrorWrapper extends PureComponent<RightMenuProps> {
state = {
hasError: false,
};
// Note: Error boundaries require class components in React - there is no hooks equivalent
// for getDerivedStateFromError and componentDidCatch.
interface RightMenuErrorWrapperState {
hasError: boolean;
}
static getDerivedStateFromError() {
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- componentDidCatch requires class component
class RightMenuErrorWrapper extends Component<
RightMenuProps & { children?: ReactNode },
RightMenuErrorWrapperState
> {
constructor(props: RightMenuProps & { children?: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): RightMenuErrorWrapperState {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('RightMenu error caught:', error, errorInfo);
}
noop = () => {};
render() {
const { children, ...rightMenuProps } = this.props;
if (this.state.hasError) {
return <RightMenu setQuery={this.noop} {...this.props} />;
return <RightMenu setQuery={this.noop} {...rightMenuProps} />;
}
return this.props.children;
return children;
}
}

View File

@@ -36,6 +36,7 @@ export interface ExtensionConfigs {
COLUMNAR_EXTENSIONS: Array<any>;
EXCEL_EXTENSIONS: Array<any>;
HAS_GSHEETS_INSTALLED: boolean;
SCARF_ANALYTICS?: boolean;
}
export interface RightMenuProps {
align: 'flex-start' | 'flex-end';

View File

@@ -24,10 +24,8 @@ import {
waitFor,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import { createMemoryHistory } from 'history';
import { ChartCreation } from 'src/pages/ChartCreation';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { supersetTheme } from '@apache-superset/core/theme';
jest.mock('src/components/DynamicPlugins', () => ({
usePluginContext: () => ({
@@ -80,24 +78,20 @@ const mockUserWithDatasetWrite: UserWithPermissionsAndRoles = {
isAnonymous: false,
groups: [],
};
const history = createMemoryHistory();
history.push = jest.fn();
const mockHistoryPush = jest.fn();
const routeProps = {
history,
location: {} as any,
match: {} as any,
};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: mockHistoryPush,
}),
}));
async function renderComponent(user = mockUser) {
mockHistoryPush.mockClear();
const rendered = render(
<ChartCreation
user={user}
addSuccessToast={() => null}
theme={supersetTheme}
{...routeProps}
/>,
<ChartCreation user={user} addSuccessToast={() => null} />,
{
useRedux: true,
useRouter: true,
@@ -171,7 +165,7 @@ test('double-click viz type does nothing if no datasource is selected', async ()
expect(
screen.getByRole('button', { name: 'Create new chart' }),
).toBeDisabled();
expect(history.push).not.toHaveBeenCalled();
expect(mockHistoryPush).not.toHaveBeenCalled();
});
test('double-click viz type submits with formatted URL if datasource is selected', async () => {
@@ -193,7 +187,7 @@ test('double-click viz type submits with formatted URL if datasource is selected
screen.getByRole('button', { name: 'Create new chart' }),
).toBeEnabled();
const formattedUrl = '/explore/?viz_type=table&datasource=table_1__table';
expect(history.push).toHaveBeenCalledWith(formattedUrl);
expect(mockHistoryPush).toHaveBeenCalledWith(formattedUrl);
});
test('dropdown displays matching datasets when user types a search term', async () => {
@@ -323,18 +317,10 @@ test('shows loading spinner when dataset parameter is present in URL', async ()
search: '?dataset=flights',
} as Location);
render(
<ChartCreation
user={mockUser}
addSuccessToast={() => null}
theme={supersetTheme}
{...routeProps}
/>,
{
useRedux: true,
useRouter: true,
},
);
render(<ChartCreation user={mockUser} addSuccessToast={() => null} />, {
useRedux: true,
useRouter: true,
});
expect(screen.getByRole('status')).toBeInTheDocument();

View File

@@ -16,15 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, ReactNode } from 'react';
import { ReactNode, useState, useEffect, useCallback, useMemo } from 'react';
import rison from 'rison';
import { t } from '@apache-superset/core/translation';
import { isDefined, JsonResponse, SupersetClient } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { withTheme, Theme } from '@emotion/react';
import { styled, useTheme } from '@apache-superset/core/theme';
import { getUrlParam } from 'src/utils/urlUtils';
import { FilterPlugins, URL_PARAMS } from 'src/constants';
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import { Link, useHistory } from 'react-router-dom';
import {
AsyncSelect,
Button,
@@ -49,20 +48,11 @@ import {
datasetLabelLower,
} from 'src/features/semanticLayers/label';
export interface ChartCreationProps extends RouteComponentProps {
export interface ChartCreationProps {
user: UserWithPermissionsAndRoles;
addSuccessToast: (arg: string) => void;
theme: Theme;
}
export type ChartCreationState = {
datasource?: { label: string | ReactNode; value: string };
datasetName?: string | string[] | null;
vizType: string | null;
canCreateDataset: boolean;
loading: boolean;
};
const ESTIMATED_NAV_HEIGHT = 56;
const ELEMENTS_EXCEPT_VIZ_GALLERY = ESTIMATED_NAV_HEIGHT + 250;
@@ -177,224 +167,221 @@ const StyledStepDescription = styled.div`
`}
`;
export class ChartCreation extends PureComponent<
ChartCreationProps,
ChartCreationState
> {
constructor(props: ChartCreationProps) {
super(props);
const hasDatasetParam = new URLSearchParams(window.location.search).has(
'dataset',
);
this.state = {
vizType: null,
canCreateDataset: findPermission(
'can_write',
'Dataset',
props.user.roles,
),
loading: hasDatasetParam,
};
export const ChartCreation = ({
user,
addSuccessToast,
}: ChartCreationProps) => {
const theme = useTheme();
const history = useHistory();
this.changeDatasource = this.changeDatasource.bind(this);
this.changeVizType = this.changeVizType.bind(this);
this.gotoSlice = this.gotoSlice.bind(this);
this.loadDatasources = this.loadDatasources.bind(this);
this.onVizTypeDoubleClick = this.onVizTypeDoubleClick.bind(this);
}
const canCreateDataset = useMemo(
() => findPermission('can_write', 'Dataset', user.roles),
[user.roles],
);
componentDidMount() {
const params = new URLSearchParams(window.location.search).get('dataset');
if (params) {
this.loadDatasources(params, 0, 1, true)
.then(r => {
const datasource = r.data[0];
this.setState({ datasource, loading: false });
})
.catch(() => {
this.setState({ loading: false });
});
this.props.addSuccessToast(t('The dataset has been saved'));
}
}
const hasDatasetParam = useMemo(
() => new URLSearchParams(window.location.search).has('dataset'),
[],
);
exploreUrl() {
const [datasource, setDatasource] = useState<
{ label: string | ReactNode; value: string } | undefined
>(undefined);
const [vizType, setVizType] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(hasDatasetParam);
const exploreUrl = useCallback(() => {
const dashboardId = getUrlParam(URL_PARAMS.dashboardId);
let url = `/explore/?viz_type=${this.state.vizType}&datasource=${this.state.datasource?.value}`;
let url = `/explore/?viz_type=${vizType}&datasource=${datasource?.value}`;
if (isDefined(dashboardId)) {
url += `&dashboard_id=${dashboardId}`;
}
return url;
}
}, [vizType, datasource?.value]);
gotoSlice() {
this.props.history.push(this.exploreUrl());
}
const gotoSlice = useCallback(() => {
history.push(exploreUrl());
}, [history, exploreUrl]);
changeDatasource(datasource: { label: string | ReactNode; value: string }) {
this.setState({ datasource });
}
const changeDatasource = useCallback(
(newDatasource: { label: string | ReactNode; value: string }) => {
setDatasource(newDatasource);
},
[],
);
changeVizType(vizType: string | null) {
this.setState({ vizType });
}
const changeVizType = useCallback((newVizType: string | null) => {
setVizType(newVizType);
}, []);
isBtnDisabled() {
return !(this.state.datasource?.value && this.state.vizType);
}
const isBtnDisabled = useCallback(
() => !(datasource?.value && vizType),
[datasource?.value, vizType],
);
onVizTypeDoubleClick() {
if (!this.isBtnDisabled()) {
this.gotoSlice();
const onVizTypeDoubleClick = useCallback(() => {
if (!isBtnDisabled()) {
gotoSlice();
}
}
}, [isBtnDisabled, gotoSlice]);
loadDatasources(
search: string,
page: number,
pageSize: number,
exactMatch = false,
) {
const query = rison.encode({
columns: [
'id',
'table_name',
'datasource_type',
'database.database_name',
'schema',
],
filters: [
{ col: 'table_name', opr: exactMatch ? 'eq' : 'ct', value: search },
],
page,
page_size: pageSize,
order_column: 'table_name',
order_direction: 'asc',
});
return SupersetClient.get({
endpoint: `/api/v1/dataset/?q=${query}`,
}).then((response: JsonResponse) => {
const list: {
id: number;
label: string | ReactNode;
value: string;
table_name: string;
}[] = response.json.result.map((item: Dataset) => ({
id: item.id,
value: `${item.id}__${item.datasource_type}`,
label: DatasetSelectLabel(item),
table_name: item.table_name,
}));
return {
data: list,
totalCount: response.json.count,
};
});
}
const loadDatasources = useCallback(
(search: string, page: number, pageSize: number, exactMatch = false) => {
const query = rison.encode({
columns: [
'id',
'table_name',
'datasource_type',
'database.database_name',
'schema',
],
filters: [
{ col: 'table_name', opr: exactMatch ? 'eq' : 'ct', value: search },
],
page,
page_size: pageSize,
order_column: 'table_name',
order_direction: 'asc',
});
return SupersetClient.get({
endpoint: `/api/v1/dataset/?q=${query}`,
}).then((response: JsonResponse) => {
const list: {
id: number;
label: string | ReactNode;
value: string;
table_name: string;
}[] = response.json.result.map((item: Dataset) => ({
id: item.id,
value: `${item.id}__${item.datasource_type}`,
label: DatasetSelectLabel(item),
table_name: item.table_name,
}));
return {
data: list,
totalCount: response.json.count,
};
});
},
[],
);
render() {
const { theme } = this.props;
const isButtonDisabled = this.isBtnDisabled();
const VIEW_INSTRUCTIONS_TEXT = t('view instructions');
const datasetHelpText = this.state.canCreateDataset ? (
<span data-test="dataset-write">
<Link to="/dataset/add/" data-test="add-chart-new-dataset">
{t('Add a dataset')}
</Link>{' '}
{t('or')}{' '}
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
data-test="add-chart-new-dataset-instructions"
>
{`${VIEW_INSTRUCTIONS_TEXT} `}
<Icons.Full iconSize="m" iconColor={theme.colorPrimary} />
</a>
.
</span>
) : (
<span data-test="no-dataset-write">
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
>
{`${VIEW_INSTRUCTIONS_TEXT} `}
<Icons.Full iconSize="m" iconColor={theme.colorPrimary} />
</a>
.
</span>
);
if (this.state.loading) {
return <Loading />;
useEffect(() => {
const params = new URLSearchParams(window.location.search).get('dataset');
if (params) {
loadDatasources(params, 0, 1, true)
.then(r => {
const newDatasource = r.data[0];
setDatasource(newDatasource);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
addSuccessToast(t('The dataset has been saved'));
}
}, [loadDatasources, addSuccessToast]);
return (
<StyledContainer>
<h3>{t('Create a new chart')}</h3>
<Steps direction="vertical" size="small">
<Steps.Step
title={
<StyledStepTitle>
{t('Choose a %s', datasetLabelLower())}
</StyledStepTitle>
}
status={this.state.datasource?.value ? 'finish' : 'process'}
description={
<StyledStepDescription className="dataset">
<AsyncSelect
autoFocus
ariaLabel={datasetLabel()}
name="select-datasource"
onChange={this.changeDatasource}
options={this.loadDatasources}
optionFilterProps={['id', 'table_name']}
placeholder={t('Choose a %s', datasetLabelLower())}
showSearch
value={this.state.datasource}
/>
{datasetHelpText}
</StyledStepDescription>
}
/>
<Steps.Step
title={<StyledStepTitle>{t('Choose chart type')}</StyledStepTitle>}
status={this.state.vizType ? 'finish' : 'process'}
description={
<StyledStepDescription>
<VizTypeGallery
denyList={denyList}
className="viz-gallery"
onChange={this.changeVizType}
onDoubleClick={this.onVizTypeDoubleClick}
selectedViz={this.state.vizType}
/>
</StyledStepDescription>
}
/>
</Steps>
<div className="footer">
{isButtonDisabled && (
<span>
{t(
'Please select both a %s and a Chart type to proceed',
datasetLabel(),
)}
</span>
)}
<Button
buttonStyle="primary"
disabled={isButtonDisabled}
onClick={this.gotoSlice}
>
{t('Create new chart')}
</Button>
</div>
</StyledContainer>
);
const isButtonDisabled = isBtnDisabled();
const VIEW_INSTRUCTIONS_TEXT = t('view instructions');
const datasetHelpText = canCreateDataset ? (
<span data-test="dataset-write">
<Link to="/dataset/add/" data-test="add-chart-new-dataset">
{t('Add a dataset')}
</Link>{' '}
{t('or')}{' '}
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
data-test="add-chart-new-dataset-instructions"
>
{`${VIEW_INSTRUCTIONS_TEXT} `}
<Icons.Full iconSize="m" iconColor={theme.colorPrimary} />
</a>
.
</span>
) : (
<span data-test="no-dataset-write">
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
>
{`${VIEW_INSTRUCTIONS_TEXT} `}
<Icons.Full iconSize="m" iconColor={theme.colorPrimary} />
</a>
.
</span>
);
if (loading) {
return <Loading />;
}
}
export default withRouter(withToasts(withTheme(ChartCreation)));
return (
<StyledContainer>
<h3>{t('Create a new chart')}</h3>
<Steps direction="vertical" size="small">
<Steps.Step
title={
<StyledStepTitle>
{t('Choose a %s', datasetLabelLower())}
</StyledStepTitle>
}
status={datasource?.value ? 'finish' : 'process'}
description={
<StyledStepDescription className="dataset">
<AsyncSelect
autoFocus
ariaLabel={datasetLabel()}
name="select-datasource"
onChange={changeDatasource}
options={loadDatasources}
optionFilterProps={['id', 'table_name']}
placeholder={t('Choose a %s', datasetLabelLower())}
showSearch
value={datasource}
/>
{datasetHelpText}
</StyledStepDescription>
}
/>
<Steps.Step
title={<StyledStepTitle>{t('Choose chart type')}</StyledStepTitle>}
status={vizType ? 'finish' : 'process'}
description={
<StyledStepDescription>
<VizTypeGallery
denyList={denyList}
className="viz-gallery"
onChange={changeVizType}
onDoubleClick={onVizTypeDoubleClick}
selectedViz={vizType}
/>
</StyledStepDescription>
}
/>
</Steps>
<div className="footer">
{isButtonDisabled && (
<span>
{t(
'Please select both a %s and a Chart type to proceed',
datasetLabel(),
)}
</span>
)}
<Button
buttonStyle="primary"
disabled={isButtonDisabled}
onClick={gotoSlice}
>
{t('Create new chart')}
</Button>
</div>
</StyledContainer>
);
};
export default withToasts(ChartCreation);

View File

@@ -1 +0,0 @@
v24.16.0

1
superset-websocket/.nvmrc Symbolic link
View File

@@ -0,0 +1 @@
../superset-frontend/.nvmrc

View File

@@ -16,6 +16,7 @@ KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Superset WebSocket Server
A Node.js WebSocket server for sending async event data to the Superset web application frontend.
@@ -164,4 +165,4 @@ HEAD /health
## Containerization
*TODO: containerize websocket server*
_TODO: containerize websocket server_

View File

@@ -1,22 +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.
*/
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

File diff suppressed because it is too large Load Diff

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