mirror of
https://github.com/apache/superset.git
synced 2026-07-24 15:45:52 +00:00
Compare commits
66 Commits
feat/i18n-
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8785ac794f | ||
|
|
0a200e6114 | ||
|
|
a2a71760ed | ||
|
|
836dce9b05 | ||
|
|
2857b29ab9 | ||
|
|
3c648ca264 | ||
|
|
25ce83cc58 | ||
|
|
ce8219672e | ||
|
|
3e957b63d4 | ||
|
|
9818da445a | ||
|
|
8262aa7d6b | ||
|
|
d4a31d1d78 | ||
|
|
18f1dd394b | ||
|
|
62ccdfacc2 | ||
|
|
c5131bff47 | ||
|
|
521e51429a | ||
|
|
64df96be00 | ||
|
|
d43a0d7633 | ||
|
|
873566c827 | ||
|
|
e063f5093c | ||
|
|
1627fab741 | ||
|
|
5dd060b714 | ||
|
|
68ebc22e1e | ||
|
|
8f75f1a353 | ||
|
|
2fac66d1a3 | ||
|
|
029d49539b | ||
|
|
3c6982252b | ||
|
|
1c7a3f395f | ||
|
|
f7b7bad9a8 | ||
|
|
0cf217cdea | ||
|
|
96bf1a1e4c | ||
|
|
9180ace1dd | ||
|
|
bb5d7dbb17 | ||
|
|
73925922a9 | ||
|
|
8f339545ad | ||
|
|
c762b75ddc | ||
|
|
c0e5f5226d | ||
|
|
d380663e71 | ||
|
|
98c3ec7a71 | ||
|
|
12f28ce235 | ||
|
|
e403d9b074 | ||
|
|
0ca51d9796 | ||
|
|
db1912c6d9 | ||
|
|
2a18a556b0 | ||
|
|
a1d1d69f5e | ||
|
|
13c5a32402 | ||
|
|
958d1ab256 | ||
|
|
b8c0171976 | ||
|
|
49dc0acd82 | ||
|
|
5e6b29d1a3 | ||
|
|
189f258e0c | ||
|
|
f5deda7864 | ||
|
|
b237aefb1e | ||
|
|
0ecf34d80e | ||
|
|
a03cabffa7 | ||
|
|
e852147182 | ||
|
|
df209cedbf | ||
|
|
b641008da6 | ||
|
|
678fcba8b4 | ||
|
|
5c12f216d5 | ||
|
|
7a752d1c22 | ||
|
|
51ce2b11ad | ||
|
|
3818152191 | ||
|
|
4dde4d2c70 | ||
|
|
35712ff977 | ||
|
|
bc80d138fd |
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
|
||||
@@ -53,6 +53,12 @@ jobs:
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
# Promotes the SQLAlchemy 2.0 deprecation warnings already locked in as
|
||||
# errors via pytest.ini's `filterwarnings` to actually run in CI, so a
|
||||
# regression on those fails the build instead of relying on a
|
||||
# contributor remembering to set this locally. See the migration
|
||||
# battleplan: https://github.com/apache/superset/discussions/40273
|
||||
SQLALCHEMY_WARN_20: "1"
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
@@ -63,7 +63,7 @@ repos:
|
||||
hooks:
|
||||
- id: prettier-frontend
|
||||
name: prettier (frontend)
|
||||
entry: bash -c 'cd superset-frontend && for file in "$@"; do npx prettier --write "${file#superset-frontend/}"; done'
|
||||
entry: bash -c 'cd superset-frontend && files=(); for f in "$@"; do files+=("${f#superset-frontend/}"); done; npx prettier --write -- "${files[@]}"' --
|
||||
language: system
|
||||
pass_filenames: true
|
||||
files: ^superset-frontend/.*\.(js|jsx|ts|tsx|css|scss|sass|json)$
|
||||
@@ -81,6 +81,12 @@ repos:
|
||||
language: system
|
||||
pass_filenames: true
|
||||
files: ^superset-frontend/.*\.(js|jsx|ts|tsx)$
|
||||
- id: stylelint-frontend
|
||||
name: stylelint (frontend css-in-js)
|
||||
entry: ./scripts/stylelint.sh
|
||||
language: system
|
||||
pass_filenames: true
|
||||
files: ^superset-frontend/.*\.(js|jsx|ts|tsx)$
|
||||
- id: eslint-docs
|
||||
name: eslint (docs)
|
||||
entry: bash -c 'cd docs && FILES=$(printf "%s\n" "$@" | sed "s|^docs/||" | tr "\n" " ") && yarn eslint --fix --quiet $FILES'
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
.codecov.yml
|
||||
.eslintrc
|
||||
.eslintignore
|
||||
.stylelintignore
|
||||
.flake8
|
||||
.nvmrc
|
||||
.prettierrc
|
||||
|
||||
10
UPDATING.md
10
UPDATING.md
@@ -153,6 +153,16 @@ virtual-dataset SQL) raises a parse error in SQL Lab and dashboard-generated
|
||||
queries. Deployments that legitimately run queries above this size should raise
|
||||
the value, and `SQL_MAX_PARSE_LENGTH = None` disables the check entirely.
|
||||
|
||||
### Ant Design upgraded from v5 to v6
|
||||
|
||||
The frontend now builds against Ant Design 6, and `@superset-ui/core` / `@apache-superset/core` peer-depend on `antd ^6`. Custom plugins, extensions, and themes that interact with Ant Design need review:
|
||||
|
||||
- **Internal DOM classes were renamed**, so any custom CSS targeting `.ant-*` internals silently stops matching. Notable renames: `.ant-tabs-content-holder` → `.ant-tabs-body-holder`, `.ant-tabs-content` → `.ant-tabs-body`, `.ant-tabs-tabpane` → `.ant-tabs-content`; `.ant-select-selector` → `.ant-select-content`, `.ant-select-selection-placeholder` → `.ant-select-placeholder`, `.ant-select-arrow` → `.ant-select-suffix`; `.ant-tooltip-inner` → `.ant-tooltip-container`; `.ant-popover-inner` → `.ant-popover-container`; `.ant-steps-item-tail` → `.ant-steps-item-rail`.
|
||||
- **Some component props changed or were removed** — e.g. `Select` no longer accepts `dropdownAlign`, `visible`/`onVisibleChange` are `open`/`onOpenChange`, `Dropdown` `overlay` is `menu`, `Steps.Step` children are the `items` prop, and `styles.body` on Tooltip/Popover is `styles.container`.
|
||||
- **CSS variables are on by default** in antd 6, and `ThemeConfig.cssVar` no longer accepts a boolean; Superset theme configs using `cssVar: true`/`false` are coerced (`true` → `{}`, `false` → omitted).
|
||||
|
||||
Theme tokens are unaffected — antd 6 removed none of the tokens Superset exposes, so existing theme configurations continue to work. See the [Ant Design v6 migration guide](https://ant.design/docs/react/migration-v6) for the complete upstream list.
|
||||
|
||||
### Guest-token RLS rules reject unknown fields
|
||||
|
||||
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
|
||||
|
||||
@@ -529,6 +529,7 @@ MCP_RESPONSE_SIZE_CONFIG = {
|
||||
"enabled": True,
|
||||
"token_limit": 25000,
|
||||
"warn_threshold_pct": 80,
|
||||
"max_list_items": 100,
|
||||
"excluded_tools": [
|
||||
"health_check",
|
||||
"get_chart_preview",
|
||||
@@ -543,6 +544,7 @@ MCP_RESPONSE_SIZE_CONFIG = {
|
||||
| `enabled` | `True` | Enable response size checking |
|
||||
| `token_limit` | `25000` | Maximum estimated token count per response |
|
||||
| `warn_threshold_pct` | `80` | Warn when response exceeds this percentage of the limit |
|
||||
| `max_list_items` | `100` | Cap on list-field length (e.g. `charts`, `native_filters`) applied to the `get_*_info` tools before falling back to more aggressive truncation. Raised from a hardcoded 30 in earlier versions; the higher default only keeps more data before the same token-budget fallback kicks in, so it's not a breaking change, but tenants that tuned workflows around the old 30-item cap should lower this value explicitly. |
|
||||
| `excluded_tools` | See above | Tools exempt from size checking (e.g., tools that return URLs, not data) |
|
||||
|
||||
### Caching
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"@swc/core": "^1.15.43",
|
||||
"antd": "^6.5.0",
|
||||
"baseline-browser-mapping": "^2.10.40",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"caniuse-lite": "^1.0.30001802",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.0",
|
||||
"js-yaml": "^5.2.0",
|
||||
|
||||
@@ -6567,10 +6567,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799:
|
||||
version "1.0.30001800"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz#b896c773e1c39400809415162bb5320371291b36"
|
||||
integrity sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001802:
|
||||
version "1.0.30001802"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz#2671fb13d468930586c56ffa80feb1c51e18ec69"
|
||||
integrity sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.19.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.20.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -74,10 +74,42 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
|-----|------|---------|-------------|
|
||||
| affinity | object | `{}` | |
|
||||
| bootstrapScript | string | see `values.yaml` | Install additional packages and do any other bootstrap configuration in this script For production clusters it's recommended to build own image with this step done in CI |
|
||||
| cache | object | `{"asyncQueries":{"keyPrefix":"qc-","timeout":86400},"cacheDb":null,"cacheUrl":null,"celeryDb":null,"celeryUrl":null,"defaultTimeout":86400,"driver":"","enabled":true,"host":null,"keyPrefix":"superset_","password":null,"port":null,"resultsBackendKeyPrefix":"superset_results","sentinel":null,"ssl":{"ca_certs":null,"certfile":null,"enabled":false,"keyfile":null,"ssl_cert_reqs":"required"},"user":""}` | Redis cache configuration for Superset Redis is optional but recommended for caching and Celery. If redis.enabled (chart dependency) is true, defaults point to the chart's Redis instance. |
|
||||
| cache.asyncQueries | object | `{"keyPrefix":"qc-","timeout":86400}` | Async queries configuration |
|
||||
| cache.cacheDb | string | `nil` | Redis database number for cache (default: 1 when unset; legacy redis_cache_db is honored) |
|
||||
| cache.cacheUrl | string | `nil` | Full Redis cache URL (overrides host/port/user/pass if set) |
|
||||
| cache.celeryDb | string | `nil` | Redis database number for Celery (default: 0 when unset; legacy redis_celery_db is honored) |
|
||||
| cache.celeryUrl | string | `nil` | Full Redis Celery URL (overrides host/port/user/pass if set) |
|
||||
| cache.defaultTimeout | int | `86400` | Default cache timeout in seconds |
|
||||
| cache.driver | string | `""` | Custom Redis driver (e.g. TLS/managed variants); overrides the redis proto in URLs when set. Ports the legacy supersetNode.connections.redis_driver escape hatch. |
|
||||
| cache.enabled | bool | `true` | Enable Redis-based features (cache, Celery). Set to false to disable Redis usage entirely. |
|
||||
| cache.host | string | `nil` | Redis host (default: {{ .Release.Name }}-redis-headless) |
|
||||
| cache.keyPrefix | string | `"superset_"` | Cache key prefix |
|
||||
| cache.password | string | `nil` | Redis password |
|
||||
| cache.port | string | `nil` | Redis port (default: 6379 when unset; legacy supersetNode.connections.redis_port is honored) |
|
||||
| cache.resultsBackendKeyPrefix | string | `"superset_results"` | Results backend key prefix |
|
||||
| cache.sentinel | string | `nil` | Redis Sentinel configuration (optional) |
|
||||
| cache.ssl | object | `{"ca_certs":null,"certfile":null,"enabled":false,"keyfile":null,"ssl_cert_reqs":"required"}` | Redis SSL configuration |
|
||||
| cache.user | string | `""` | Redis user (optional, for Redis ACL) |
|
||||
| cluster | object | `{"databaseServiceName":null,"domain":".svc.cluster.local","redisServiceName":null,"websocketServiceName":null}` | Kubernetes cluster configuration Used for constructing service URLs between chart components |
|
||||
| cluster.databaseServiceName | string | `nil` | Database service name (default: {{ .Release.Name }}-postgresql) Override if using a different service name for the database |
|
||||
| cluster.domain | string | `".svc.cluster.local"` | Kubernetes cluster domain (default: .svc.cluster.local) Override if using a custom cluster domain |
|
||||
| cluster.redisServiceName | string | `nil` | Redis service name (default: {{ .Release.Name }}-redis-headless) Override if using a different service name for Redis |
|
||||
| cluster.websocketServiceName | string | `nil` | WebSocket service name (default: {{ .Release.Name }}-ws) Override if using a different service name for the WebSocket service |
|
||||
| config | object | `{}` | Superset configuration properties Set any configuration property from superset/config.py here See https://github.com/apache/superset/blob/master/superset/config.py for all available options |
|
||||
| configFromSecret | string | `"{{ template \"superset.fullname\" . }}-config"` | The name of the secret which we will use to generate a superset_config.py file Note: this secret must have the key superset_config.py in it and can include other files as well |
|
||||
| configMountPath | string | `"/app/pythonpath"` | |
|
||||
| configOverrides | object | `{}` | A dictionary of overrides to append at the end of superset_config.py - the name does not matter WARNING: the order is not guaranteed Files can be passed as helm --set-file configOverrides.my-override=my-file.py |
|
||||
| configOverridesFiles | object | `{}` | Same as above but the values are files |
|
||||
| database | object | `{"driver":null,"host":null,"name":null,"password":null,"port":null,"ssl":{"enabled":false,"mode":"require"},"uri":null,"user":null}` | Database connection configuration for the Superset metadata database |
|
||||
| database.driver | string | `nil` | Database driver used when uri is not set (default: postgresql+psycopg2 when unset; legacy supersetNode.connections.db_type is honored when this is unset) |
|
||||
| database.host | string | `nil` | Database host (default: {{ .Release.Name }}-postgresql) |
|
||||
| database.name | string | `nil` | Database name (default: superset, resolved via superset.db.name) |
|
||||
| database.password | string | `nil` | Database password (default: superset, resolved via superset.db.password) ⚠️ CHANGE THIS for production |
|
||||
| database.port | string | `nil` | Database port (default: 5432 when unset; legacy supersetNode.connections.db_port is honored) |
|
||||
| database.ssl | object | `{"enabled":false,"mode":"require"}` | Database SSL configuration |
|
||||
| database.uri | string | `nil` | Full database URI (overrides host/port/user/pass/name if set) Example: "postgresql+psycopg2://user:pass@host:5432/dbname" |
|
||||
| database.user | string | `nil` | Database user (default: superset, resolved via superset.db.user) |
|
||||
| envFromSecret | string | `"{{ template \"superset.fullname\" . }}-env"` | The name of the secret which we will use to populate env vars in deployed pods This can be useful for secret keys, etc. |
|
||||
| envFromSecrets | list | `[]` | This can be a list of templated strings |
|
||||
| extraConfigMountPath | string | `"/app/configs"` | |
|
||||
@@ -89,7 +121,9 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| extraSecrets | object | `{}` | Extra files to be mounted as Secrets on the path specified in `configMountPath` |
|
||||
| extraVolumeMounts | list | `[]` | |
|
||||
| extraVolumes | list | `[]` | |
|
||||
| featureFlags | object | `{}` | Feature flags configuration See https://github.com/apache/superset/blob/master/RESOURCES/FEATURE_FLAGS.md |
|
||||
| fullnameOverride | string | `nil` | Provide a name to override the full names of resources |
|
||||
| globalPodAnnotations | object | `{}` | Global pod annotations to be added to all pods Use this to set annotations that apply to all Superset components Component-specific podAnnotations will be merged with these global annotations |
|
||||
| hostAliases | list | `[]` | Custom hostAliases for all superset pods # https://kubernetes.io/docs/tasks/network/customize-hosts-file-for-pods/ |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| image.repository | string | `"apachesuperset.docker.scarf.sh/apache/superset"` | |
|
||||
@@ -116,7 +150,7 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| init.enabled | bool | `true` | |
|
||||
| init.extraContainers | list | `[]` | Launch additional containers into init job pod |
|
||||
| init.initContainers | list | a container waiting for postgres | List of initContainers |
|
||||
| init.initscript | string | a script to create admin user and initialize roles | A Superset init script |
|
||||
| init.initscript | string | unused; kept for backwards-compatibility only | DEPRECATED: this field is no longer used by the chart. The init script is rendered entirely from the internal `superset.initScript` template (which runs `superset db upgrade`, `superset init`, admin creation, and examples). Any customization placed here is silently ignored. See UPGRADING.md. |
|
||||
| init.jobAnnotations."helm.sh/hook" | string | `"post-install,post-upgrade"` | |
|
||||
| init.jobAnnotations."helm.sh/hook-delete-policy" | string | `"before-hook-creation"` | |
|
||||
| init.loadExamples | bool | `false` | |
|
||||
@@ -143,6 +177,7 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| service.type | string | `"ClusterIP"` | |
|
||||
| serviceAccount.annotations | object | `{}` | |
|
||||
| serviceAccount.create | bool | `false` | Create custom service account for Superset. If create: true and serviceAccountName is not provided, `superset.fullname` will be used. |
|
||||
| serviceAccount.name | string | `""` | Service account name to use (if not specified, defaults to release name + chart name) |
|
||||
| serviceAccountName | string | `nil` | Specify service account name to be used |
|
||||
| supersetCeleryBeat.affinity | object | `{}` | Affinity to be added to supersetCeleryBeat deployment |
|
||||
| supersetCeleryBeat.command | list | a `celery beat` command | Command |
|
||||
@@ -214,20 +249,7 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| supersetNode.autoscaling.minReplicas | int | `1` | |
|
||||
| supersetNode.autoscaling.targetCPUUtilizationPercentage | int | `80` | |
|
||||
| supersetNode.command | list | See `values.yaml` | Startup command |
|
||||
| supersetNode.connections.db_host | string | `"{{ .Release.Name }}-postgresql"` | |
|
||||
| supersetNode.connections.db_name | string | `"superset"` | |
|
||||
| supersetNode.connections.db_pass | string | `"superset"` | |
|
||||
| supersetNode.connections.db_port | string | `"5432"` | |
|
||||
| supersetNode.connections.db_type | string | `"postgresql"` | Database type for Superset metadata (Supported types: "postgresql", "mysql") |
|
||||
| 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.connections | object | `{}` | |
|
||||
| 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 |
|
||||
@@ -334,6 +356,11 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
|
||||
| supersetWorker.deploymentLabels | object | `{}` | Labels to be added to supersetWorker deployment |
|
||||
| supersetWorker.extraContainers | list | `[]` | Launch additional containers into supersetWorker pod |
|
||||
| supersetWorker.forceReload | bool | `false` | If true, forces deployment to reload on each upgrade |
|
||||
| supersetWorker.healthCheck | object | `{"enabled":false,"livenessFile":"/tmp/celery_worker_alive","livenessHeartbeatInterval":10,"readinessFile":"/tmp/celery_worker_ready"}` | Celery worker file-based health check (worker writes readiness/liveness files via signals; point supersetWorker.readinessProbe/livenessProbe at these files to use them) |
|
||||
| supersetWorker.healthCheck.enabled | bool | `false` | Enable the file-based Celery worker health check |
|
||||
| supersetWorker.healthCheck.livenessFile | string | `"/tmp/celery_worker_alive"` | Liveness file (touched periodically by a heartbeat thread) |
|
||||
| supersetWorker.healthCheck.livenessHeartbeatInterval | int | `10` | Seconds between liveness heartbeats |
|
||||
| supersetWorker.healthCheck.readinessFile | string | `"/tmp/celery_worker_ready"` | Readiness file (created when the worker is ready, removed on shutdown) |
|
||||
| supersetWorker.initContainers | list | a container waiting for postgres and redis | Init container |
|
||||
| supersetWorker.lifecycle | object | `{}` | Container lifecycle hooks for the worker pod |
|
||||
| supersetWorker.livenessProbe.exec.command | list | a `celery inspect ping` command | Liveness probe command |
|
||||
|
||||
161
helm/superset/UPGRADING.md
Normal file
161
helm/superset/UPGRADING.md
Normal file
@@ -0,0 +1,161 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Upgrading the Superset Helm Chart
|
||||
|
||||
## Upgrading to chart 0.20.0
|
||||
|
||||
Chart 0.20.0 introduces a structured connection schema. The old keys listed below are **DEPRECATED** — they still work (auto-mapped to the new keys at render time) and a deprecation warning is printed in `helm install`/`helm upgrade` NOTES, but they will be removed in a future release. Migrate as soon as possible.
|
||||
|
||||
### 1. Connection schema: `supersetNode.connections.*` → `database.*` / `cache.*`
|
||||
|
||||
**Before (deprecated — still honored):**
|
||||
|
||||
```yaml
|
||||
supersetNode:
|
||||
connections:
|
||||
db_host: "pg-host"
|
||||
db_port: "5432"
|
||||
db_user: "superset"
|
||||
db_pass: "superset"
|
||||
db_name: "superset"
|
||||
redis_host: "redis-host"
|
||||
redis_port: "6379"
|
||||
redis_cache_db: "1"
|
||||
redis_celery_db: "0"
|
||||
redis_driver: "rediss"
|
||||
```
|
||||
|
||||
**After (new structured keys — recommended):**
|
||||
|
||||
```yaml
|
||||
database:
|
||||
host: "pg-host"
|
||||
port: 5432
|
||||
user: "superset"
|
||||
password: "superset"
|
||||
name: "superset"
|
||||
|
||||
cache:
|
||||
host: "redis-host"
|
||||
port: 6379
|
||||
cacheDb: 1
|
||||
celeryDb: 0
|
||||
driver: "rediss" # optional: custom Redis driver / TLS variant
|
||||
```
|
||||
|
||||
Key-by-key mapping summary:
|
||||
|
||||
| Old key | New key |
|
||||
|---|---|
|
||||
| `supersetNode.connections.db_host` | `database.host` |
|
||||
| `supersetNode.connections.db_port` | `database.port` |
|
||||
| `supersetNode.connections.db_user` | `database.user` |
|
||||
| `supersetNode.connections.db_pass` | `database.password` |
|
||||
| `supersetNode.connections.db_name` | `database.name` |
|
||||
| `supersetNode.connections.redis_host` | `cache.host` |
|
||||
| `supersetNode.connections.redis_port` | `cache.port` |
|
||||
| `supersetNode.connections.redis_cache_db` | `cache.cacheDb` |
|
||||
| `supersetNode.connections.redis_celery_db` | `cache.celeryDb` |
|
||||
| `supersetNode.connections.redis_driver` | `cache.driver` |
|
||||
|
||||
### 2. Service account: root `serviceAccountName` → `serviceAccount.name`
|
||||
|
||||
**Before (deprecated — still honored):**
|
||||
|
||||
```yaml
|
||||
serviceAccountName: my-sa
|
||||
```
|
||||
|
||||
**After (new key — recommended):**
|
||||
|
||||
```yaml
|
||||
serviceAccount:
|
||||
name: my-sa
|
||||
```
|
||||
|
||||
### 3. Init script: `init.initscript` is deprecated and replaced by the built-in template
|
||||
|
||||
> **This is a behavior change.** The chart no longer uses `init.initscript`. The init script is rendered
|
||||
> entirely from an internal chart template (`superset.initScript`), which runs the full initialization
|
||||
> sequence:
|
||||
>
|
||||
> 1. `superset db upgrade` — applies all pending database schema migrations
|
||||
> 2. `superset init` — initializes roles and permissions
|
||||
> 3. Admin user creation (when `init.createAdmin: true`)
|
||||
> 4. Example data loading (when `init.loadExamples: true`)
|
||||
> 5. Datasource import (when `import_datasources.yaml` is present)
|
||||
>
|
||||
> A future PR will optionally split the database migration step into a dedicated upgrade Job for
|
||||
> zero-downtime deployments. Until that PR lands, migrations run as part of the init Job above.
|
||||
|
||||
If you customized `init.initscript` in your `values.yaml`, that customization is silently ignored.
|
||||
Move any customizations to `config` or `configOverrides`:
|
||||
|
||||
```yaml
|
||||
# Move custom Python config here:
|
||||
config:
|
||||
MY_SETTING: "value"
|
||||
|
||||
configOverrides:
|
||||
my_custom_override: |
|
||||
# Python snippet appended to superset_config.py
|
||||
MY_SETTING = "value"
|
||||
```
|
||||
|
||||
If your use case cannot be covered by `config` or `configOverrides`, please open an issue so the maintainers
|
||||
can evaluate extending the template.
|
||||
|
||||
### 4. New top-level sections: `config.*` and `featureFlags.*`
|
||||
|
||||
Two new sections provide direct Superset configuration passthrough without needing raw `configOverrides`.
|
||||
|
||||
**`config.*` — direct Superset config properties:**
|
||||
|
||||
```yaml
|
||||
config:
|
||||
SECRET_KEY: "$(SUPERSET_SECRET_KEY)"
|
||||
ROW_LIMIT: 50000
|
||||
WTF_CSRF_ENABLED: true
|
||||
SQLALCHEMY_POOL_SIZE: 10
|
||||
```
|
||||
|
||||
Each key is injected verbatim into `superset_config.py`. String values are quoted; non-string values
|
||||
(integers, booleans) are rendered as-is.
|
||||
|
||||
**`featureFlags.*` — feature flag overrides:**
|
||||
|
||||
```yaml
|
||||
featureFlags:
|
||||
ALERT_REPORTS: true
|
||||
DASHBOARD_RBAC: true
|
||||
ENABLE_TEMPLATE_PROCESSING: false
|
||||
```
|
||||
|
||||
This is equivalent to setting `FEATURE_FLAGS = {...}` in `superset_config.py`, but is more readable and
|
||||
schema-validated.
|
||||
|
||||
### 5. `values.schema.json` — early validation
|
||||
|
||||
The chart now ships a `values.schema.json` that validates the values you provide. Wrong types (e.g., a string
|
||||
where an integer is expected) or unknown keys in structured sections will cause `helm install`/`helm upgrade`
|
||||
to fail immediately with a descriptive error instead of producing a broken deployment.
|
||||
|
||||
If you get a validation error after upgrading, check that your overridden values match the types documented in
|
||||
`values.yaml` and the schema.
|
||||
@@ -35,3 +35,9 @@
|
||||
echo "Visit http://127.0.0.1:8088 to use your application"
|
||||
kubectl port-forward service/superset 8088:8088 --namespace {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
{{- $warnings := include "superset.deprecationWarnings" . }}
|
||||
{{- if trim $warnings }}
|
||||
|
||||
⚠️ DEPRECATION WARNINGS
|
||||
{{ $warnings }}
|
||||
{{- end }}
|
||||
|
||||
@@ -47,11 +47,12 @@ If release name contains chart name it will be used as a full name.
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "superset.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
{{- default (include "superset.fullname" .) .Values.serviceAccountName -}}
|
||||
{{- else -}}
|
||||
{{- default "default" .Values.serviceAccountName -}}
|
||||
{{- end -}}
|
||||
{{- $name := coalesce .Values.serviceAccount.name .Values.serviceAccountName -}}
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
{{- default (include "superset.fullname" .) $name -}}
|
||||
{{- else -}}
|
||||
{{- default "default" $name -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
@@ -105,80 +106,572 @@ app.kubernetes.io/component: {{ .component }}
|
||||
{{- end -}}
|
||||
|
||||
|
||||
{{- define "superset-config" }}
|
||||
{{/*
|
||||
Coalescing resolvers for DB and Redis connection parameters.
|
||||
Each resolver checks (in order):
|
||||
1. New top-level database.* / cache.* values
|
||||
2. Legacy supersetNode.connections.* keys (deprecation path, using safe index to avoid errors on absent maps)
|
||||
3. cluster.* service name overrides
|
||||
4. Hard-coded defaults derived from the release name
|
||||
Call with root context: {{ include "superset.db.host" . }}
|
||||
*/}}
|
||||
|
||||
{{/*
|
||||
Helper to safely read .Values.supersetNode.connections.<key> without erroring when maps are absent.
|
||||
*/}}
|
||||
{{- define "_superset.legacyConn" -}}
|
||||
{{- $sn := index .Values "supersetNode" | default dict -}}
|
||||
{{- $conn := index $sn "connections" | default dict -}}
|
||||
{{- $conn | toJson -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.db.host" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- tpl (coalesce .Values.database.host (index $conn "db_host") .Values.cluster.databaseServiceName (printf "%s-postgresql" .Release.Name)) $ -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.db.port" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- if .Values.database.port -}}
|
||||
{{- .Values.database.port | toString -}}
|
||||
{{- else -}}
|
||||
{{- coalesce (index $conn "db_port") "5432" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.db.user" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- coalesce .Values.database.user (index $conn "db_user") "superset" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.db.password" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- coalesce .Values.database.password (index $conn "db_pass") "superset" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.db.name" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- coalesce .Values.database.name (index $conn "db_name") "superset" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.db.driver" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- coalesce .Values.database.driver (index $conn "db_type") "postgresql+psycopg2" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.redis.host" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- tpl (coalesce .Values.cache.host (index $conn "redis_host") .Values.cluster.redisServiceName (printf "%s-redis-headless" .Release.Name)) $ -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.redis.port" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- if .Values.cache.port -}}
|
||||
{{- .Values.cache.port | toString -}}
|
||||
{{- else -}}
|
||||
{{- coalesce (index $conn "redis_port") "6379" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.redis.user" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- coalesce .Values.cache.user (index $conn "redis_user") "" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.redis.password" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- coalesce .Values.cache.password (index $conn "redis_password") "" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.redis.cacheDb" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- if not (kindIs "invalid" .Values.cache.cacheDb) -}}
|
||||
{{- .Values.cache.cacheDb | toString -}}
|
||||
{{- else -}}
|
||||
{{- coalesce (index $conn "redis_cache_db") "1" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.redis.celeryDb" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- if not (kindIs "invalid" .Values.cache.celeryDb) -}}
|
||||
{{- .Values.cache.celeryDb | toString -}}
|
||||
{{- else -}}
|
||||
{{- coalesce (index $conn "redis_celery_db") "0" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.redis.proto" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- if .Values.cache.driver }}{{ .Values.cache.driver }}{{- else if index $conn "redis_driver" }}{{ index $conn "redis_driver" }}{{- else if .Values.cache.ssl.enabled }}rediss{{- else }}redis{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.config" }}
|
||||
{{- /* SECURITY: Validate admin password is set if admin creation is enabled */}}
|
||||
{{- if and .Values.init.createAdmin (or (not .Values.init.adminUser.password) (eq .Values.init.adminUser.password "")) }}
|
||||
{{- fail "SECURITY ERROR: init.createAdmin is true but init.adminUser.password is empty. You must set a secure password using --set init.adminUser.password='your-password' or via external secret." }}
|
||||
{{- end }}
|
||||
|
||||
import os
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
{{- if or .Values.config.cacheConfig .Values.config.dataCacheConfig .Values.config.resultsBackend .Values.config.celeryConfig .Values.cache.enabled }}
|
||||
from flask_caching.backends.rediscache import RedisCache
|
||||
{{- end }}
|
||||
|
||||
def env(key, default=None):
|
||||
return os.getenv(key, default)
|
||||
|
||||
# Redis Base URL
|
||||
{{- if .Values.supersetNode.connections.redis_password }}
|
||||
REDIS_BASE_URL=f"{env('REDIS_DRIVER') or env('REDIS_PROTO')}://{env('REDIS_USER', '')}:{env('REDIS_PASSWORD')}@{env('REDIS_HOST')}:{env('REDIS_PORT')}"
|
||||
{{- /* Database Configuration - Superset always requires a database */}}
|
||||
{{- if .Values.database.uri }}
|
||||
SQLALCHEMY_DATABASE_URI = {{ .Values.database.uri | quote }}
|
||||
{{- else }}
|
||||
REDIS_BASE_URL=f"{env('REDIS_DRIVER') or env('REDIS_PROTO')}://{env('REDIS_HOST')}:{env('REDIS_PORT')}"
|
||||
{{- $driver := include "superset.db.driver" . }}
|
||||
{{- $sslParams := "" }}
|
||||
{{- if and (hasKey .Values.database "ssl") .Values.database.ssl.enabled }}
|
||||
{{- $sslMode := .Values.database.ssl.mode | default "require" }}
|
||||
{{- $sslParams = printf "?sslmode=%s" $sslMode }}
|
||||
{{- end }}
|
||||
SQLALCHEMY_DATABASE_URI = f"{{ $driver }}://{quote(env('DB_USER', ''), safe='')}:{quote(env('DB_PASS', ''), safe='')}@{env('DB_HOST')}:{env('DB_PORT')}/{env('DB_NAME')}{{ $sslParams }}"
|
||||
{{- end }}
|
||||
{{- if hasKey .Values.config "SQLALCHEMY_TRACK_MODIFICATIONS" }}
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = {{ .Values.config.SQLALCHEMY_TRACK_MODIFICATIONS | toString | title }}
|
||||
{{- else }}
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
{{- end }}
|
||||
|
||||
# Redis URL Params
|
||||
{{- if .Values.supersetNode.connections.redis_ssl.enabled }}
|
||||
REDIS_URL_PARAMS = f"?ssl_cert_reqs={env('REDIS_SSL_CERT_REQS')}"
|
||||
{{- /* Redis Configuration - only if Redis cache is configured */}}
|
||||
{{- if .Values.cache.enabled }}
|
||||
{{- if .Values.cache.cacheUrl }}
|
||||
CACHE_REDIS_URL = {{ .Values.cache.cacheUrl | quote }}
|
||||
{{- else }}
|
||||
{{- $useSSL := and (hasKey .Values.cache "ssl") .Values.cache.ssl.enabled }}
|
||||
_redis_user = quote(env('REDIS_USER', ''), safe='')
|
||||
_redis_password = quote(env('REDIS_PASSWORD', ''), safe='')
|
||||
_redis_auth = f"{_redis_user}:{_redis_password}@" if (_redis_user or _redis_password) else ""
|
||||
REDIS_BASE_URL = f"{{ include "superset.redis.proto" . }}://{_redis_auth}{env('REDIS_HOST')}:{env('REDIS_PORT')}"
|
||||
{{- if $useSSL }}
|
||||
{{- $sslCertReqs := .Values.cache.ssl.ssl_cert_reqs | default "required" }}
|
||||
REDIS_URL_PARAMS = f"?ssl_cert_reqs={{ $sslCertReqs }}"
|
||||
{{- else }}
|
||||
REDIS_URL_PARAMS = ""
|
||||
{{- end}}
|
||||
{{- end }}
|
||||
{{- $cacheDb := include "superset.redis.cacheDb" . }}
|
||||
CACHE_REDIS_URL = f"{REDIS_BASE_URL}/{{ $cacheDb }}{REDIS_URL_PARAMS}"
|
||||
{{- end }}
|
||||
{{- if .Values.cache.celeryUrl }}
|
||||
CELERY_REDIS_URL = {{ .Values.cache.celeryUrl | quote }}
|
||||
{{- else if not .Values.cache.cacheUrl }}
|
||||
{{- $celeryDb := include "superset.redis.celeryDb" . }}
|
||||
CELERY_REDIS_URL = f"{REDIS_BASE_URL}/{{ $celeryDb }}{REDIS_URL_PARAMS}"
|
||||
{{- else }}
|
||||
{{- if or .Values.config.celeryConfig (not .Values.cache.enabled) }}
|
||||
{{- /* Custom celeryConfig provided or cache disabled - OK */}}
|
||||
{{- else }}
|
||||
{{- fail "CONFIGURATION ERROR: cache.cacheUrl is set but cache.celeryUrl is not set. When using cacheUrl, you must also set celeryUrl for Celery to work. Alternatively, set config.celeryConfig to provide a custom Celery configuration." }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
# Build Redis URLs
|
||||
CACHE_REDIS_URL = f"{REDIS_BASE_URL}/{env('REDIS_DB', 1)}{REDIS_URL_PARAMS}"
|
||||
CELERY_REDIS_URL = f"{REDIS_BASE_URL}/{env('REDIS_CELERY_DB', 0)}{REDIS_URL_PARAMS}"
|
||||
|
||||
MAPBOX_API_KEY = env('MAPBOX_API_KEY', '')
|
||||
{{- /* Cache Configuration */}}
|
||||
{{- if .Values.config.cacheConfig }}
|
||||
CACHE_CONFIG = json.loads({{ .Values.config.cacheConfig | toJson | quote }})
|
||||
{{- else if .Values.cache.enabled }}
|
||||
CACHE_CONFIG = {
|
||||
'CACHE_TYPE': 'RedisCache',
|
||||
'CACHE_DEFAULT_TIMEOUT': 300,
|
||||
'CACHE_KEY_PREFIX': 'superset_',
|
||||
'CACHE_REDIS_URL': CACHE_REDIS_URL,
|
||||
'CACHE_TYPE': 'RedisCache',
|
||||
'CACHE_DEFAULT_TIMEOUT': {{ .Values.cache.defaultTimeout | default (.Values.config.cacheDefaultTimeout | default 86400) | int }},
|
||||
'CACHE_KEY_PREFIX': {{ .Values.cache.keyPrefix | default "superset_" | quote }},
|
||||
'CACHE_REDIS_URL': CACHE_REDIS_URL,
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.config.dataCacheConfig }}
|
||||
DATA_CACHE_CONFIG = json.loads({{ .Values.config.dataCacheConfig | toJson | quote }})
|
||||
{{- else if .Values.config.cacheConfig }}
|
||||
DATA_CACHE_CONFIG = CACHE_CONFIG
|
||||
{{- else if .Values.cache.enabled }}
|
||||
DATA_CACHE_CONFIG = CACHE_CONFIG
|
||||
{{- end }}
|
||||
|
||||
{{- /* SQLLAB_ASYNC_TIME_LIMIT_SEC - Required for async_queries module import (default: 6 hours) */}}
|
||||
{{- if .Values.config.SQLLAB_ASYNC_TIME_LIMIT_SEC }}
|
||||
SQLLAB_ASYNC_TIME_LIMIT_SEC = {{ .Values.config.SQLLAB_ASYNC_TIME_LIMIT_SEC | int }}
|
||||
{{- else }}
|
||||
from datetime import timedelta
|
||||
SQLLAB_ASYNC_TIME_LIMIT_SEC = int(timedelta(hours=6).total_seconds())
|
||||
{{- end }}
|
||||
|
||||
if os.getenv("SQLALCHEMY_DATABASE_URI"):
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI")
|
||||
else:
|
||||
{{- if eq .Values.supersetNode.connections.db_type "postgresql" }}
|
||||
SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{os.getenv('DB_USER')}:{os.getenv('DB_PASS')}@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
|
||||
{{- else if eq .Values.supersetNode.connections.db_type "mysql" }}
|
||||
SQLALCHEMY_DATABASE_URI = f"mysql+mysqldb://{os.getenv('DB_USER')}:{os.getenv('DB_PASS')}@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}"
|
||||
{{- else }}
|
||||
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
|
||||
{{- end }}
|
||||
{{- /* Celery Configuration */}}
|
||||
{{- if .Values.config.celeryConfig }}
|
||||
{{- if kindIs "string" .Values.config.celeryConfig }}
|
||||
{{ .Values.config.celeryConfig }}
|
||||
{{- else }}
|
||||
class CeleryConfig:
|
||||
{{- range $key, $value := .Values.config.celeryConfig }}
|
||||
{{ $key }} = json.loads({{ $value | toJson | quote }})
|
||||
{{- end }}
|
||||
|
||||
{{- if hasKey .Values.config.celeryConfig "imports" }}
|
||||
CELERY_IMPORTS = CeleryConfig.imports
|
||||
{{- else }}
|
||||
CELERY_IMPORTS = ()
|
||||
{{- end }}
|
||||
CELERY_CONFIG = CeleryConfig
|
||||
{{- end }}
|
||||
{{- else if .Values.cache.enabled }}
|
||||
from celery.schedules import crontab
|
||||
from datetime import timedelta
|
||||
|
||||
class CeleryConfig:
|
||||
imports = ("superset.sql_lab", )
|
||||
broker_url = CELERY_REDIS_URL
|
||||
result_backend = CELERY_REDIS_URL
|
||||
imports = (
|
||||
"superset.sql_lab",
|
||||
"superset.tasks.scheduler",
|
||||
"superset.tasks.thumbnails",
|
||||
"superset.tasks.cache",
|
||||
)
|
||||
broker_connection_retry_on_startup = True
|
||||
worker_prefetch_multiplier = 10
|
||||
task_acks_late = True
|
||||
broker_url = CELERY_REDIS_URL
|
||||
result_backend = CELERY_REDIS_URL
|
||||
task_annotations = {
|
||||
"sql_lab.get_sql_results": {
|
||||
"rate_limit": "100/s",
|
||||
},
|
||||
}
|
||||
beat_schedule = {
|
||||
"reports.scheduler": {
|
||||
"task": "reports.scheduler",
|
||||
"schedule": crontab(minute="*", hour="*"),
|
||||
"options": {"expires": int(timedelta(weeks=1).total_seconds())},
|
||||
},
|
||||
"reports.prune_log": {
|
||||
"task": "reports.prune_log",
|
||||
"schedule": crontab(minute=0, hour=0),
|
||||
},
|
||||
}
|
||||
|
||||
CELERY_IMPORTS = CeleryConfig.imports
|
||||
CELERY_CONFIG = CeleryConfig
|
||||
RESULTS_BACKEND = RedisCache(
|
||||
host=env('REDIS_HOST'),
|
||||
{{- if .Values.supersetNode.connections.redis_password }}
|
||||
password=env('REDIS_PASSWORD'),
|
||||
{{- end }}
|
||||
port=env('REDIS_PORT'),
|
||||
key_prefix='superset_results',
|
||||
{{- if .Values.supersetNode.connections.redis_ssl.enabled }}
|
||||
ssl=True,
|
||||
ssl_cert_reqs=env('REDIS_SSL_CERT_REQS'),
|
||||
{{- end }}
|
||||
)
|
||||
{{- end }}
|
||||
|
||||
{{ if .Values.configOverrides }}
|
||||
# Overrides
|
||||
{{- /* Celery Worker Health Check - File-based health probes for Kubernetes */}}
|
||||
{{- if and .Values.supersetWorker.healthCheck .Values.supersetWorker.healthCheck.enabled }}
|
||||
# Celery Worker Health Check Configuration
|
||||
import threading
|
||||
from celery import bootsteps
|
||||
from celery.signals import worker_ready, worker_shutdown, worker_init
|
||||
|
||||
_readiness_file = {{ .Values.supersetWorker.healthCheck.readinessFile | default "/tmp/celery_worker_ready" | quote }}
|
||||
_liveness_file = {{ .Values.supersetWorker.healthCheck.livenessFile | default "/tmp/celery_worker_alive" | quote }}
|
||||
_heartbeat_interval = {{ .Values.supersetWorker.healthCheck.livenessHeartbeatInterval | default 10 | int }}
|
||||
_liveness_thread = None
|
||||
_liveness_stop_event = None
|
||||
|
||||
@worker_ready.connect
|
||||
def create_ready_file(sender, **kwargs):
|
||||
try:
|
||||
open(_readiness_file, 'w').close()
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not create readiness file: {e}")
|
||||
|
||||
@worker_shutdown.connect
|
||||
def remove_ready_file(sender, **kwargs):
|
||||
global _liveness_thread, _liveness_stop_event
|
||||
if _liveness_stop_event:
|
||||
_liveness_stop_event.set()
|
||||
if _liveness_thread:
|
||||
_liveness_thread.join(timeout=5)
|
||||
try:
|
||||
if os.path.exists(_readiness_file):
|
||||
os.remove(_readiness_file)
|
||||
if os.path.exists(_liveness_file):
|
||||
os.remove(_liveness_file)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not remove health check files: {e}")
|
||||
|
||||
@worker_init.connect
|
||||
def start_liveness_heartbeat(sender, **kwargs):
|
||||
global _liveness_thread, _liveness_stop_event
|
||||
_liveness_stop_event = threading.Event()
|
||||
|
||||
def update_liveness():
|
||||
while not _liveness_stop_event.is_set():
|
||||
try:
|
||||
with open(_liveness_file, 'w') as f:
|
||||
f.write(str(os.getpid()))
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not update liveness file: {e}")
|
||||
_liveness_stop_event.wait(_heartbeat_interval)
|
||||
|
||||
_liveness_thread = threading.Thread(target=update_liveness, daemon=True)
|
||||
_liveness_thread.start()
|
||||
{{- else }}
|
||||
CELERY_WORKER_HEALTH_CHECK_ENABLED = False
|
||||
{{- end }}
|
||||
|
||||
{{- /* Results Backend */}}
|
||||
{{- $redisHostForBackend := include "superset.redis.host" . }}
|
||||
{{- $redisPortForBackend := include "superset.redis.port" . }}
|
||||
{{- $redisPasswordForBackend := include "superset.redis.password" . }}
|
||||
{{- if .Values.config.resultsBackend }}
|
||||
{{- if kindIs "string" .Values.config.resultsBackend }}
|
||||
RESULTS_BACKEND = {{ .Values.config.resultsBackend }}
|
||||
{{- else }}
|
||||
RESULTS_BACKEND = RedisCache(
|
||||
host={{ $redisHostForBackend | quote }},
|
||||
{{- if $redisPasswordForBackend }}
|
||||
password={{ $redisPasswordForBackend | quote }},
|
||||
{{- end }}
|
||||
port={{ $redisPortForBackend | int }},
|
||||
key_prefix={{ .Values.cache.resultsBackendKeyPrefix | default "superset_results" | quote }},
|
||||
{{- if and (hasKey .Values.cache "ssl") .Values.cache.ssl.enabled }}
|
||||
ssl=True,
|
||||
ssl_cert_reqs={{ .Values.cache.ssl.ssl_cert_reqs | default "required" | quote }},
|
||||
{{- end }}
|
||||
)
|
||||
{{- end }}
|
||||
{{- else if .Values.cache.enabled }}
|
||||
RESULTS_BACKEND = RedisCache(
|
||||
host={{ $redisHostForBackend | quote }},
|
||||
{{- if $redisPasswordForBackend }}
|
||||
password={{ $redisPasswordForBackend | quote }},
|
||||
{{- end }}
|
||||
port={{ $redisPortForBackend | int }},
|
||||
key_prefix={{ .Values.cache.resultsBackendKeyPrefix | default "superset_results" | quote }},
|
||||
{{- if and (hasKey .Values.cache "ssl") .Values.cache.ssl.enabled }}
|
||||
ssl=True,
|
||||
ssl_cert_reqs={{ .Values.cache.ssl.ssl_cert_reqs | default "required" | quote }},
|
||||
{{- end }}
|
||||
)
|
||||
{{- end }}
|
||||
|
||||
{{- /* Global Async Queries Cache Backend */}}
|
||||
{{- $redisUserForGaq := include "superset.redis.user" . }}
|
||||
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_CACHE_BACKEND }}
|
||||
GLOBAL_ASYNC_QUERIES_CACHE_BACKEND = json.loads({{ .Values.config.GLOBAL_ASYNC_QUERIES_CACHE_BACKEND | toJson | quote }})
|
||||
{{- else if .Values.cache.enabled }}
|
||||
GLOBAL_ASYNC_QUERIES_CACHE_BACKEND = {
|
||||
"CACHE_TYPE": "RedisCache",
|
||||
"CACHE_REDIS_HOST": {{ $redisHostForBackend | quote }},
|
||||
"CACHE_REDIS_PORT": {{ $redisPortForBackend | int }},
|
||||
{{- if $redisUserForGaq }}
|
||||
"CACHE_REDIS_USER": {{ $redisUserForGaq | quote }},
|
||||
{{- end }}
|
||||
{{- if $redisPasswordForBackend }}
|
||||
"CACHE_REDIS_PASSWORD": {{ $redisPasswordForBackend | quote }},
|
||||
{{- else }}
|
||||
"CACHE_REDIS_PASSWORD": "",
|
||||
{{- end }}
|
||||
"CACHE_REDIS_DB": {{ .Values.cache.asyncQueries.db | default .Values.cache.cacheDb | default 0 | int }},
|
||||
"CACHE_KEY_PREFIX": {{ .Values.cache.asyncQueries.keyPrefix | default "qc-" | quote }},
|
||||
"CACHE_DEFAULT_TIMEOUT": {{ .Values.cache.asyncQueries.timeout | default 86400 | int }},
|
||||
{{- if and .Values.cache.sentinel .Values.cache.sentinel.enabled }}
|
||||
{{- if .Values.cache.sentinel.sentinels }}
|
||||
"CACHE_REDIS_SENTINELS": {{ .Values.cache.sentinel.sentinels | toJson }},
|
||||
{{- else }}
|
||||
{{- fail "CONFIGURATION ERROR: cache.sentinel.enabled is true but cache.sentinel.sentinels is not set. You must provide Sentinel host(s) in cache.sentinel.sentinels (e.g., [['sentinel-host', 26379]])." }}
|
||||
{{- end }}
|
||||
"CACHE_REDIS_SENTINEL_MASTER": {{ .Values.cache.sentinel.master | default "mymaster" | quote }},
|
||||
{{- if .Values.cache.sentinel.password }}
|
||||
"CACHE_REDIS_SENTINEL_PASSWORD": {{ .Values.cache.sentinel.password | quote }},
|
||||
{{- else }}
|
||||
"CACHE_REDIS_SENTINEL_PASSWORD": None,
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if and (hasKey .Values.cache "ssl") .Values.cache.ssl.enabled }}
|
||||
"CACHE_REDIS_SSL": True,
|
||||
"CACHE_REDIS_SSL_CERTFILE": {{ if .Values.cache.ssl.certfile }}{{ .Values.cache.ssl.certfile | quote }}{{ else }}None{{ end }},
|
||||
"CACHE_REDIS_SSL_KEYFILE": {{ if .Values.cache.ssl.keyfile }}{{ .Values.cache.ssl.keyfile | quote }}{{ else }}None{{ end }},
|
||||
"CACHE_REDIS_SSL_CERT_REQS": {{ .Values.cache.ssl.ssl_cert_reqs | default "required" | quote }},
|
||||
"CACHE_REDIS_SSL_CA_CERTS": {{ if .Values.cache.ssl.ca_certs }}{{ .Values.cache.ssl.ca_certs | quote }}{{ else }}None{{ end }},
|
||||
{{- else }}
|
||||
"CACHE_REDIS_SSL": False,
|
||||
"CACHE_REDIS_SSL_CERTFILE": None,
|
||||
"CACHE_REDIS_SSL_KEYFILE": None,
|
||||
"CACHE_REDIS_SSL_CERT_REQS": {{ .Values.cache.ssl.ssl_cert_reqs | default "required" | quote }},
|
||||
"CACHE_REDIS_SSL_CA_CERTS": None,
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
{{- /* Global Async Queries Results Backend */}}
|
||||
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_RESULTS_BACKEND }}
|
||||
GLOBAL_ASYNC_QUERIES_RESULTS_BACKEND = json.loads({{ .Values.config.GLOBAL_ASYNC_QUERIES_RESULTS_BACKEND | toJson | quote }})
|
||||
{{- else if .Values.cache.enabled }}
|
||||
GLOBAL_ASYNC_QUERIES_RESULTS_BACKEND = {
|
||||
"backend": "redis",
|
||||
"host": {{ $redisHostForBackend | quote }},
|
||||
"port": {{ $redisPortForBackend | int }},
|
||||
"prefix": {{ .Values.cache.asyncQueries.keyPrefix | default "qc-" | quote }},
|
||||
"db": {{ .Values.cache.asyncQueries.db | default .Values.cache.cacheDb | default 0 | int }},
|
||||
{{- if $redisPasswordForBackend }}
|
||||
"password": {{ $redisPasswordForBackend | quote }},
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
{{- /* Feature Flags */}}
|
||||
{{- if .Values.featureFlags }}
|
||||
FEATURE_FLAGS = {
|
||||
{{- range $key, $value := .Values.featureFlags }}
|
||||
{{- if kindIs "bool" $value }}
|
||||
"{{ $key }}": {{ if $value }}True{{ else }}False{{ end }},
|
||||
{{- else if kindIs "string" $value }}
|
||||
"{{ $key }}": {{ $value | quote }},
|
||||
{{- else if kindIs "float64" $value }}
|
||||
"{{ $key }}": {{ $value }},
|
||||
{{- else if kindIs "int" $value }}
|
||||
"{{ $key }}": {{ $value }},
|
||||
{{- else if kindIs "invalid" $value }}
|
||||
"{{ $key }}": None,
|
||||
{{- else }}
|
||||
"{{ $key }}": json.loads({{ $value | toJson | quote }}),
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
{{- /* FAB Security API - Required for List Roles view in 6.0.0+ */}}
|
||||
{{- if not (hasKey .Values.config "FAB_ADD_SECURITY_API") }}
|
||||
FAB_ADD_SECURITY_API = True
|
||||
{{- end }}
|
||||
{{- if not (hasKey .Values.config "FAB_ADD_SECURITY_VIEWS") }}
|
||||
FAB_ADD_SECURITY_VIEWS = True
|
||||
{{- end }}
|
||||
|
||||
{{- /* Global Async Queries Transport - Auto-configure for websockets if enabled */}}
|
||||
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_TRANSPORT }}
|
||||
GLOBAL_ASYNC_QUERIES_TRANSPORT = {{ .Values.config.GLOBAL_ASYNC_QUERIES_TRANSPORT | quote }}
|
||||
{{- else if .Values.supersetWebsockets.enabled }}
|
||||
GLOBAL_ASYNC_QUERIES_TRANSPORT = "ws"
|
||||
{{- else }}
|
||||
GLOBAL_ASYNC_QUERIES_TRANSPORT = "polling"
|
||||
{{- end }}
|
||||
|
||||
{{- /* Global Async Queries WebSocket URL */}}
|
||||
{{- $wsUrl := "" }}
|
||||
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL }}
|
||||
{{- $wsUrl = .Values.config.GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL }}
|
||||
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = {{ $wsUrl | quote }}
|
||||
{{- else if and .Values.supersetWebsockets.enabled .Values.supersetWebsockets.websocketUrl }}
|
||||
{{- $wsUrl = .Values.supersetWebsockets.websocketUrl }}
|
||||
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = {{ $wsUrl | quote }}
|
||||
{{- else if .Values.supersetWebsockets.enabled }}
|
||||
{{- $wsServiceName := .Values.cluster.websocketServiceName }}
|
||||
{{- if not $wsServiceName }}
|
||||
{{- $wsServiceName = printf "%s-ws" (include "superset.fullname" .) }}
|
||||
{{- end }}
|
||||
{{- $wsPort := .Values.supersetWebsockets.service.port | default 8080 }}
|
||||
{{- $wsPath := "/ws" }}
|
||||
{{- $clusterDomain := .Values.cluster.domain | default ".svc.cluster.local" }}
|
||||
{{- $wsUrl = printf "ws://%s.%s%s:%d%s" $wsServiceName .Release.Namespace $clusterDomain $wsPort $wsPath }}
|
||||
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = {{ $wsUrl | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- /* Global Async Queries JWT Secret */}}
|
||||
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_JWT_SECRET }}
|
||||
GLOBAL_ASYNC_QUERIES_JWT_SECRET = {{ .Values.config.GLOBAL_ASYNC_QUERIES_JWT_SECRET | quote }}
|
||||
{{- else if and .Values.supersetWebsockets.enabled .Values.supersetWebsockets.config.jwtSecret }}
|
||||
GLOBAL_ASYNC_QUERIES_JWT_SECRET = {{ .Values.supersetWebsockets.config.jwtSecret | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- /* Global Async Queries JWT Cookie Settings */}}
|
||||
{{- if hasKey .Values.config "GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE" }}
|
||||
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE = {{ .Values.config.GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE | toString | title }}
|
||||
{{- else if and .Values.supersetWebsockets.enabled (or (hasPrefix "wss://" $wsUrl) .Values.ingress.tls) }}
|
||||
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE = True
|
||||
{{- else if .Values.supersetWebsockets.enabled }}
|
||||
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE = False
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE }}
|
||||
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE = {{ .Values.config.GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE | quote }}
|
||||
{{- else if .Values.supersetWebsockets.enabled }}
|
||||
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE = "Lax"
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.config.GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME }}
|
||||
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME = {{ .Values.config.GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME | quote }}
|
||||
{{- else if and .Values.supersetWebsockets.enabled .Values.supersetWebsockets.config.jwtCookieName }}
|
||||
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME = {{ .Values.supersetWebsockets.config.jwtCookieName | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- /* Content Security Policy (CSP) */}}
|
||||
{{- if and .Values.supersetWebsockets.enabled $wsUrl (not (hasKey .Values.config "TALISMAN_CONFIG")) }}
|
||||
TALISMAN_CONFIG = {
|
||||
"content_security_policy": {
|
||||
"base-uri": ["'self'"],
|
||||
"default-src": ["'self'"],
|
||||
"img-src": [
|
||||
"'self'",
|
||||
"blob:",
|
||||
"data:",
|
||||
"https://apachesuperset.gateway.scarf.sh",
|
||||
"https://static.scarf.sh/",
|
||||
"ows.terrestris.de",
|
||||
"https://cdn.document360.io",
|
||||
],
|
||||
"worker-src": ["'self'", "blob:"],
|
||||
"connect-src": [
|
||||
"'self'",
|
||||
{{ $wsUrl | quote }},
|
||||
"https://api.mapbox.com",
|
||||
"https://events.mapbox.com",
|
||||
"https://tile.openstreetmap.org",
|
||||
"https://tile.osm.ch",
|
||||
],
|
||||
"object-src": "'none'",
|
||||
"style-src": [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
],
|
||||
"script-src": ["'self'", "'strict-dynamic'"],
|
||||
},
|
||||
"content_security_policy_nonce_in": ["script-src"],
|
||||
{{- if or (hasPrefix "wss://" $wsUrl) .Values.ingress.tls }}
|
||||
"force_https": True,
|
||||
"session_cookie_secure": True,
|
||||
{{- else }}
|
||||
"force_https": False,
|
||||
"session_cookie_secure": False,
|
||||
{{- end }}
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
{{- /* General Configuration - iterate through all config values */}}
|
||||
{{- range $key, $value := .Values.config }}
|
||||
{{- if and (ne $key "cacheConfig") (ne $key "dataCacheConfig") (ne $key "celeryConfig") (ne $key "resultsBackend") (ne $key "GLOBAL_ASYNC_QUERIES_CACHE_BACKEND") (ne $key "GLOBAL_ASYNC_QUERIES_RESULTS_BACKEND") (ne $key "GLOBAL_ASYNC_QUERIES_TRANSPORT") (ne $key "GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL") (ne $key "GLOBAL_ASYNC_QUERIES_JWT_SECRET") (ne $key "GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE") (ne $key "GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE") (ne $key "GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME") (ne $key "TALISMAN_CONFIG") (ne $key "SQLLAB_ASYNC_TIME_LIMIT_SEC") (ne $key "SQLALCHEMY_TRACK_MODIFICATIONS") }}
|
||||
{{- if kindIs "map" $value }}
|
||||
{{ $key }} = json.loads({{ $value | toJson | quote }})
|
||||
{{- else if kindIs "slice" $value }}
|
||||
{{ $key }} = json.loads({{ $value | toJson | quote }})
|
||||
{{- else if kindIs "bool" $value }}
|
||||
{{ $key }} = {{ if $value }}True{{ else }}False{{ end }}
|
||||
{{- else if kindIs "string" $value }}
|
||||
{{- if or (hasPrefix "f\"" $value) (hasPrefix "F\"" $value) (hasPrefix "r\"" $value) (hasPrefix "R\"" $value) (hasPrefix "b\"" $value) (hasPrefix "B\"" $value) }}
|
||||
{{ $key }} = {{ $value }}
|
||||
{{- else }}
|
||||
{{ $key }} = {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- else if kindIs "invalid" $value }}
|
||||
{{ $key }} = None
|
||||
{{- else }}
|
||||
{{ $key }} = {{ $value | toJson }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- /* Custom Config Overrides */}}
|
||||
{{- if .Values.configOverrides }}
|
||||
# Custom Overrides
|
||||
{{- range $key, $value := .Values.configOverrides }}
|
||||
# {{ $key }}
|
||||
{{ tpl $value $ }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{ if .Values.configOverridesFiles }}
|
||||
{{- if .Values.configOverridesFiles }}
|
||||
# Overrides from files
|
||||
{{- $files := .Files }}
|
||||
{{- range $key, $value := .Values.configOverridesFiles }}
|
||||
@@ -187,7 +680,57 @@ RESULTS_BACKEND = RedisCache(
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- end -}}
|
||||
|
||||
{{- define "superset.initScript" -}}
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
echo "Upgrading DB schema..."
|
||||
superset db upgrade
|
||||
echo "Initializing roles and permissions..."
|
||||
superset init
|
||||
echo "Init job: Creating admin user and loading initial data..."
|
||||
{{- if .Values.init.createAdmin }}
|
||||
echo "Creating admin user (if not present)..."
|
||||
if superset fab list-users 2>/dev/null | grep -qF {{ printf "username:%s" .Values.init.adminUser.username | squote }}; then
|
||||
echo "Admin user already exists, skipping."
|
||||
else
|
||||
superset fab create-admin \
|
||||
--username {{ .Values.init.adminUser.username | squote }} \
|
||||
--firstname {{ .Values.init.adminUser.firstname | squote }} \
|
||||
--lastname {{ .Values.init.adminUser.lastname | squote }} \
|
||||
--email {{ .Values.init.adminUser.email | squote }} \
|
||||
--password {{ .Values.init.adminUser.password | squote }}
|
||||
fi
|
||||
{{- else }}
|
||||
echo "Skipping admin creation (init.createAdmin=false)"
|
||||
{{- end }}
|
||||
{{- if .Values.init.loadExamples }}
|
||||
echo "Loading examples..."
|
||||
superset load_examples
|
||||
{{- else }}
|
||||
echo "Skipping examples (init.loadExamples=false)"
|
||||
{{- end }}
|
||||
if [ -f "{{ .Values.extraConfigMountPath }}/import_datasources.yaml" ]; then
|
||||
echo "Importing database connections..."
|
||||
superset import_datasources -p {{ .Values.extraConfigMountPath }}/import_datasources.yaml
|
||||
fi
|
||||
echo "Init job complete."
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Deprecation warnings — returns a newline-separated list of active deprecation messages,
|
||||
or empty string when no deprecated keys are set. Rendered in NOTES.txt after install/upgrade.
|
||||
*/}}
|
||||
{{- define "superset.deprecationWarnings" -}}
|
||||
{{- $conn := include "_superset.legacyConn" . | fromJson -}}
|
||||
{{- if gt (len (keys $conn)) 0 }}
|
||||
- supersetNode.connections.* is deprecated; use database.* and cache.* (auto-mapped for now). See UPGRADING.md
|
||||
{{- end }}
|
||||
{{- if .Values.serviceAccountName }}
|
||||
- root serviceAccountName is deprecated; use serviceAccount.name (auto-mapped for now). See UPGRADING.md
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "supersetNode.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "superset.name" . }}
|
||||
|
||||
@@ -37,9 +37,12 @@ spec:
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/superset_config.py: {{ include "superset-config" . | sha256sum }}
|
||||
checksum/superset_config.py: {{ include "superset.config" . | sha256sum }}
|
||||
checksum/superset_bootstrap.sh: {{ tpl .Values.bootstrapScript . | sha256sum }}
|
||||
checksum/connections: {{ .Values.supersetNode.connections | toYaml | sha256sum }}
|
||||
checksum/database: {{ .Values.database | toYaml | sha256sum }}
|
||||
checksum/redis: {{ .Values.cache | toYaml | sha256sum }}
|
||||
checksum/config: {{ .Values.config | toYaml | sha256sum }}
|
||||
checksum/featureFlags: {{ .Values.featureFlags | toYaml | sha256sum }}
|
||||
checksum/extraConfigs: {{ .Values.extraConfigs | toYaml | sha256sum }}
|
||||
checksum/extraSecrets: {{ .Values.extraSecrets | toYaml | sha256sum }}
|
||||
checksum/extraSecretEnv: {{ .Values.extraSecretEnv | toYaml | sha256sum }}
|
||||
@@ -52,6 +55,9 @@ spec:
|
||||
{{- if .Values.supersetCeleryBeat.podAnnotations }}
|
||||
{{- toYaml .Values.supersetCeleryBeat.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.globalPodAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "supersetCeleryBeat.selectorLabels" . | nindent 8 }}
|
||||
{{- if .Values.supersetCeleryBeat.podLabels }}
|
||||
|
||||
@@ -36,11 +36,14 @@ spec:
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include "superset-config" . | sha256sum }}
|
||||
checksum/config: {{ include "superset.config" . | sha256sum }}
|
||||
checksum/secrets: {{ tpl (toJson .Values.extraSecretEnv) . | sha256sum }}
|
||||
{{- if .Values.supersetCeleryFlower.podAnnotations }}
|
||||
{{- toYaml .Values.supersetCeleryFlower.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.globalPodAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "supersetCeleryFlower.selectorLabels" . | nindent 8 }}
|
||||
{{- if .Values.supersetCeleryFlower.podLabels }}
|
||||
|
||||
@@ -43,9 +43,12 @@ spec:
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/superset_config.py: {{ include "superset-config" . | sha256sum }}
|
||||
checksum/superset_config.py: {{ include "superset.config" . | sha256sum }}
|
||||
checksum/superset_bootstrap.sh: {{ tpl .Values.bootstrapScript . | sha256sum }}
|
||||
checksum/connections: {{ .Values.supersetNode.connections | toYaml | sha256sum }}
|
||||
checksum/database: {{ .Values.database | toYaml | sha256sum }}
|
||||
checksum/redis: {{ .Values.cache | toYaml | sha256sum }}
|
||||
checksum/config: {{ .Values.config | toYaml | sha256sum }}
|
||||
checksum/featureFlags: {{ .Values.featureFlags | toYaml | sha256sum }}
|
||||
checksum/extraConfigs: {{ .Values.extraConfigs | toYaml | sha256sum }}
|
||||
checksum/extraSecrets: {{ .Values.extraSecrets | toYaml | sha256sum }}
|
||||
checksum/extraSecretEnv: {{ .Values.extraSecretEnv | toYaml | sha256sum }}
|
||||
@@ -58,6 +61,9 @@ spec:
|
||||
{{- if .Values.supersetWorker.podAnnotations }}
|
||||
{{- toYaml .Values.supersetWorker.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.globalPodAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "supersetWorker.selectorLabels" . | nindent 8 }}
|
||||
{{- if .Values.supersetWorker.podLabels }}
|
||||
|
||||
@@ -44,6 +44,9 @@ spec:
|
||||
{{- if .Values.supersetWebsockets.podAnnotations }}
|
||||
{{- toYaml .Values.supersetWebsockets.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.globalPodAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "supersetWebsockets.selectorLabels" . | nindent 8 }}
|
||||
{{- if .Values.supersetWebsockets.podLabels }}
|
||||
|
||||
@@ -44,10 +44,13 @@ spec:
|
||||
metadata:
|
||||
annotations:
|
||||
# Force reload on config changes
|
||||
checksum/superset_config.py: {{ include "superset-config" . | sha256sum }}
|
||||
checksum/superset_init.sh: {{ tpl .Values.init.initscript . | sha256sum }}
|
||||
checksum/superset_config.py: {{ include "superset.config" . | sha256sum }}
|
||||
checksum/superset_init.sh: {{ include "superset.initScript" . | sha256sum }}
|
||||
checksum/superset_bootstrap.sh: {{ tpl .Values.bootstrapScript . | sha256sum }}
|
||||
checksum/connections: {{ .Values.supersetNode.connections | toYaml | sha256sum }}
|
||||
checksum/database: {{ .Values.database | toYaml | sha256sum }}
|
||||
checksum/redis: {{ .Values.cache | toYaml | sha256sum }}
|
||||
checksum/config: {{ .Values.config | toYaml | sha256sum }}
|
||||
checksum/featureFlags: {{ .Values.featureFlags | toYaml | sha256sum }}
|
||||
checksum/extraConfigs: {{ .Values.extraConfigs | toYaml | sha256sum }}
|
||||
checksum/extraSecrets: {{ .Values.extraSecrets | toYaml | sha256sum }}
|
||||
checksum/extraSecretEnv: {{ .Values.extraSecretEnv | toYaml | sha256sum }}
|
||||
@@ -60,6 +63,9 @@ spec:
|
||||
{{- if .Values.supersetNode.podAnnotations }}
|
||||
{{- toYaml .Values.supersetNode.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.globalPodAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "supersetNode.selectorLabels" . | nindent 8 }}
|
||||
{{- if .Values.supersetNode.podLabels }}
|
||||
|
||||
@@ -26,24 +26,28 @@ metadata:
|
||||
{{- include "superset.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
REDIS_HOST: {{ tpl .Values.supersetNode.connections.redis_host . | quote }}
|
||||
REDIS_USER: {{ .Values.supersetNode.connections.redis_user | quote }}
|
||||
{{- if .Values.supersetNode.connections.redis_password }}
|
||||
REDIS_PASSWORD: {{ .Values.supersetNode.connections.redis_password | quote }}
|
||||
DB_HOST: {{ include "superset.db.host" . | quote }}
|
||||
DB_PORT: {{ include "superset.db.port" . | quote }}
|
||||
DB_USER: {{ include "superset.db.user" . | quote }}
|
||||
DB_PASS: {{ include "superset.db.password" . | quote }}
|
||||
DB_NAME: {{ include "superset.db.name" . | quote }}
|
||||
{{- if .Values.database.ssl.enabled }}
|
||||
DB_SSL_MODE: {{ .Values.database.ssl.mode | default "require" | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.cache.enabled }}
|
||||
REDIS_HOST: {{ include "superset.redis.host" . | quote }}
|
||||
REDIS_PORT: {{ include "superset.redis.port" . | quote }}
|
||||
REDIS_USER: {{ include "superset.redis.user" . | quote }}
|
||||
{{- if include "superset.redis.password" . }}
|
||||
REDIS_PASSWORD: {{ include "superset.redis.password" . | quote }}
|
||||
{{- end }}
|
||||
REDIS_DB: {{ include "superset.redis.cacheDb" . | quote }}
|
||||
REDIS_CELERY_DB: {{ include "superset.redis.celeryDb" . | quote }}
|
||||
REDIS_PROTO: {{ include "superset.redis.proto" . | quote }}
|
||||
{{- if .Values.cache.driver }}
|
||||
REDIS_DRIVER: {{ .Values.cache.driver | quote }}
|
||||
{{- 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 }}
|
||||
REDIS_SSL_CERT_REQS: {{ .Values.supersetNode.connections.redis_ssl.ssl_cert_reqs | default "CERT_NONE" | quote }}
|
||||
{{- end }}
|
||||
DB_HOST: {{ tpl .Values.supersetNode.connections.db_host . | quote }}
|
||||
DB_PORT: {{ .Values.supersetNode.connections.db_port | quote }}
|
||||
DB_USER: {{ .Values.supersetNode.connections.db_user | quote }}
|
||||
DB_PASS: {{ .Values.supersetNode.connections.db_pass | quote }}
|
||||
DB_NAME: {{ .Values.supersetNode.connections.db_name | quote }}
|
||||
{{- if .Values.extraSecretEnv }}
|
||||
{{- range $key, $value := .Values.extraSecretEnv }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
|
||||
@@ -27,9 +27,9 @@ metadata:
|
||||
type: Opaque
|
||||
stringData:
|
||||
superset_config.py: |
|
||||
{{- include "superset-config" . | nindent 4 }}
|
||||
{{- include "superset.config" . | nindent 4 }}
|
||||
superset_init.sh: |
|
||||
{{- tpl .Values.init.initscript . | nindent 4 }}
|
||||
{{- include "superset.initScript" . | nindent 4 }}
|
||||
superset_bootstrap.sh: |
|
||||
{{- tpl .Values.bootstrapScript . | nindent 4 }}
|
||||
|
||||
|
||||
132
helm/superset/tests/checksum_test.yaml
Normal file
132
helm/superset/tests/checksum_test.yaml
Normal file
@@ -0,0 +1,132 @@
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
suite: config checksums + global pod annotations
|
||||
templates:
|
||||
- deployment.yaml
|
||||
- deployment-worker.yaml
|
||||
- deployment-beat.yaml
|
||||
- deployment-flower.yaml
|
||||
- deployment-ws.yaml
|
||||
tests:
|
||||
- it: main deployment has new config checksums, not the old connections checksum
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- exists:
|
||||
path: 'spec.template.metadata.annotations["checksum/database"]'
|
||||
- exists:
|
||||
path: 'spec.template.metadata.annotations["checksum/config"]'
|
||||
- notExists:
|
||||
path: 'spec.template.metadata.annotations["checksum/connections"]'
|
||||
- it: main deployment applies globalPodAnnotations
|
||||
template: deployment.yaml
|
||||
set:
|
||||
globalPodAnnotations:
|
||||
team: data
|
||||
asserts:
|
||||
- equal:
|
||||
path: 'spec.template.metadata.annotations.team'
|
||||
value: data
|
||||
- it: globalPodAnnotations coexist with per-component podAnnotations on deployment.yaml
|
||||
template: deployment.yaml
|
||||
set:
|
||||
globalPodAnnotations:
|
||||
global: "yes"
|
||||
supersetNode:
|
||||
podAnnotations:
|
||||
component: "yes"
|
||||
asserts:
|
||||
- equal:
|
||||
path: 'spec.template.metadata.annotations.global'
|
||||
value: "yes"
|
||||
- equal:
|
||||
path: 'spec.template.metadata.annotations.component'
|
||||
value: "yes"
|
||||
- it: worker deployment has config and database checksums
|
||||
template: deployment-worker.yaml
|
||||
asserts:
|
||||
- exists:
|
||||
path: 'spec.template.metadata.annotations["checksum/database"]'
|
||||
- exists:
|
||||
path: 'spec.template.metadata.annotations["checksum/config"]'
|
||||
- it: worker deployment applies globalPodAnnotations
|
||||
template: deployment-worker.yaml
|
||||
set:
|
||||
globalPodAnnotations:
|
||||
team: data
|
||||
asserts:
|
||||
- equal:
|
||||
path: 'spec.template.metadata.annotations.team'
|
||||
value: data
|
||||
- it: beat deployment has config and database checksums
|
||||
template: deployment-beat.yaml
|
||||
set:
|
||||
supersetCeleryBeat:
|
||||
enabled: true
|
||||
asserts:
|
||||
- exists:
|
||||
path: 'spec.template.metadata.annotations["checksum/database"]'
|
||||
- exists:
|
||||
path: 'spec.template.metadata.annotations["checksum/config"]'
|
||||
- it: beat deployment applies globalPodAnnotations
|
||||
template: deployment-beat.yaml
|
||||
set:
|
||||
supersetCeleryBeat:
|
||||
enabled: true
|
||||
globalPodAnnotations:
|
||||
team: data
|
||||
asserts:
|
||||
- equal:
|
||||
path: 'spec.template.metadata.annotations.team'
|
||||
value: data
|
||||
- it: flower deployment has config checksum
|
||||
template: deployment-flower.yaml
|
||||
set:
|
||||
supersetCeleryFlower:
|
||||
enabled: true
|
||||
asserts:
|
||||
- exists:
|
||||
path: 'spec.template.metadata.annotations["checksum/config"]'
|
||||
- it: flower deployment applies globalPodAnnotations
|
||||
template: deployment-flower.yaml
|
||||
set:
|
||||
supersetCeleryFlower:
|
||||
enabled: true
|
||||
globalPodAnnotations:
|
||||
team: data
|
||||
asserts:
|
||||
- equal:
|
||||
path: 'spec.template.metadata.annotations.team'
|
||||
value: data
|
||||
- it: websockets deployment has wsconfig checksum
|
||||
template: deployment-ws.yaml
|
||||
set:
|
||||
supersetWebsockets:
|
||||
enabled: true
|
||||
asserts:
|
||||
- exists:
|
||||
path: 'spec.template.metadata.annotations["checksum/wsconfig"]'
|
||||
- it: websockets deployment applies globalPodAnnotations
|
||||
template: deployment-ws.yaml
|
||||
set:
|
||||
supersetWebsockets:
|
||||
enabled: true
|
||||
globalPodAnnotations:
|
||||
team: data
|
||||
asserts:
|
||||
- equal:
|
||||
path: 'spec.template.metadata.annotations.team'
|
||||
value: data
|
||||
94
helm/superset/tests/config_test.yaml
Normal file
94
helm/superset/tests/config_test.yaml
Normal file
@@ -0,0 +1,94 @@
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
suite: superset_config.py rendering
|
||||
templates:
|
||||
- secret-superset-config.yaml
|
||||
tests:
|
||||
- it: builds SQLALCHEMY_DATABASE_URI from env with percent-encoded creds
|
||||
set: {database: {host: pg.svc, port: 5432, user: su, password: pw, name: sset}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'SQLALCHEMY_DATABASE_URI = f"postgresql\+psycopg2://\{quote\(env\(.DB_USER'}
|
||||
- it: honors database.uri override
|
||||
set: {database.uri: "postgresql://x:y@h:5432/d"}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'SQLALCHEMY_DATABASE_URI = "postgresql://x:y@h:5432/d"'}
|
||||
- it: imports required for env/json handling are present
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'import json'}
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'from urllib\.parse import quote'}
|
||||
- it: redis creds are percent-encoded and read from env
|
||||
set: {cache: {enabled: true, password: "p@ss"}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'quote\(env\(.REDIS_PASSWORD'}
|
||||
- it: config map values render via json.loads
|
||||
set: {config: {MY_MAP: {enabled: true}}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'MY_MAP = json\.loads\('}
|
||||
- it: config nil values render as None
|
||||
set: {config: {MY_NIL: null}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'MY_NIL = None'}
|
||||
- it: supersetWorker healthCheck enabled renders readiness file path
|
||||
set: {supersetWorker: {healthCheck: {enabled: true}}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: '/tmp/celery_worker_ready'}
|
||||
- it: SQLALCHEMY_TRACK_MODIFICATIONS defaults False
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'SQLALCHEMY_TRACK_MODIFICATIONS = False'}
|
||||
- it: renders feature flags
|
||||
set: {featureFlags: {ALERT_REPORTS: true}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: '"ALERT_REPORTS": True'}
|
||||
- it: SQLALCHEMY_TRACK_MODIFICATIONS user-set true renders Python True
|
||||
set: {config: {SQLALCHEMY_TRACK_MODIFICATIONS: true}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'SQLALCHEMY_TRACK_MODIFICATIONS = True'}
|
||||
- it: celeryConfig without imports emits a safe empty CELERY_IMPORTS (no AttributeError)
|
||||
set: {config: {celeryConfig: {broker_url: "redis://x"}}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'CELERY_IMPORTS = \(\)'}
|
||||
- notMatchRegex: {path: 'stringData["superset_config.py"]', pattern: 'CELERY_IMPORTS = CeleryConfig\.imports'}
|
||||
- it: legacy db_type maps to the SQLAlchemy driver (deprecation path)
|
||||
set: {supersetNode: {connections: {db_type: "postgresql+pg8000"}}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'SQLALCHEMY_DATABASE_URI = f"postgresql\+pg8000://'}
|
||||
- it: init script single-quotes the admin username (no shell injection)
|
||||
set: {init: {createAdmin: true, adminUser: {password: "x"}}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_init.sh"]', pattern: "grep -qF 'username:"}
|
||||
- notMatchRegex: {path: 'stringData["superset_init.sh"]', pattern: 'create-admin[\s\S]*\|\| true'}
|
||||
- it: sentinel disabled does not fail render
|
||||
set: {cache: {enabled: true, sentinel: {enabled: false}}}
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 1
|
||||
- it: ssl certfile path is quoted
|
||||
set: {cache: {enabled: true, ssl: {enabled: true, certfile: "/etc/ssl/cert.pem"}}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: '"CACHE_REDIS_SSL_CERTFILE": "/etc/ssl/cert.pem"'}
|
||||
- it: explicit JWT cookie secure false is respected
|
||||
set: {supersetWebsockets: {enabled: true}, config: {GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE: false}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE = False'}
|
||||
- it: celeryConfig non-string values render as valid Python via json.loads
|
||||
set: {config: {celeryConfig: {broker_url: "redis://x", task_acks_late: true}}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: 'task_acks_late = json\.loads\('}
|
||||
- it: featureFlags non-scalar values render as valid Python via json.loads
|
||||
set: {featureFlags: {SOME_MAP: {nested: true}}}
|
||||
asserts:
|
||||
- matchRegex: {path: 'stringData["superset_config.py"]', pattern: '"SOME_MAP": json\.loads\('}
|
||||
28
helm/superset/tests/deprecation_test.yaml
Normal file
28
helm/superset/tests/deprecation_test.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
suite: deprecation path does not fail render
|
||||
templates:
|
||||
- secret-superset-config.yaml
|
||||
tests:
|
||||
- it: legacy supersetNode.connections still renders (no fail)
|
||||
set: {supersetNode.connections.db_host: legacy}
|
||||
asserts:
|
||||
- hasDocuments: {count: 1}
|
||||
- it: legacy root serviceAccountName still renders (no fail)
|
||||
set: {serviceAccountName: my-sa}
|
||||
asserts:
|
||||
- hasDocuments: {count: 1}
|
||||
63
helm/superset/tests/initscript_test.yaml
Normal file
63
helm/superset/tests/initscript_test.yaml
Normal file
@@ -0,0 +1,63 @@
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
suite: superset_init.sh rendering
|
||||
templates:
|
||||
- secret-superset-config.yaml
|
||||
tests:
|
||||
- it: renders superset db upgrade in init script
|
||||
asserts:
|
||||
- matchRegex:
|
||||
path: 'stringData["superset_init.sh"]'
|
||||
pattern: 'superset db upgrade'
|
||||
- it: renders superset init in init script
|
||||
asserts:
|
||||
- matchRegex:
|
||||
path: 'stringData["superset_init.sh"]'
|
||||
pattern: 'superset init'
|
||||
- it: renders db upgrade before admin creation
|
||||
set:
|
||||
init:
|
||||
createAdmin: true
|
||||
adminUser:
|
||||
username: admin
|
||||
firstname: Admin
|
||||
lastname: User
|
||||
email: admin@example.com
|
||||
password: adminpass
|
||||
asserts:
|
||||
- matchRegex:
|
||||
path: 'stringData["superset_init.sh"]'
|
||||
pattern: 'superset db upgrade'
|
||||
- matchRegex:
|
||||
path: 'stringData["superset_init.sh"]'
|
||||
pattern: 'superset init'
|
||||
- matchRegex:
|
||||
path: 'stringData["superset_init.sh"]'
|
||||
pattern: 'fab create-admin'
|
||||
- it: admin creation is idempotent and does not swallow real failures
|
||||
set:
|
||||
init:
|
||||
createAdmin: true
|
||||
adminUser:
|
||||
password: "x"
|
||||
asserts:
|
||||
- matchRegex:
|
||||
path: 'stringData["superset_init.sh"]'
|
||||
pattern: 'superset fab list-users'
|
||||
- notMatchRegex:
|
||||
path: 'stringData["superset_init.sh"]'
|
||||
pattern: 'create-admin.*\|\| true'
|
||||
71
helm/superset/tests/secret_env_test.yaml
Normal file
71
helm/superset/tests/secret_env_test.yaml
Normal file
@@ -0,0 +1,71 @@
|
||||
#
|
||||
# Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
# contributor license agreements. See the NOTICE file distributed with
|
||||
# this work for additional information regarding copyright ownership.
|
||||
# The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
# (the "License"); you may not use this file except in compliance with
|
||||
# the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
suite: secret-env schema + deprecation mapping
|
||||
templates:
|
||||
- secret-env.yaml
|
||||
tests:
|
||||
- it: emits DB_* and REDIS_* from new schema
|
||||
set:
|
||||
database: {host: pg.svc, port: 5432, user: su, password: pw, name: superset}
|
||||
cache: {enabled: true, host: rd.svc, port: 6379, cacheDb: 1, celeryDb: 0}
|
||||
asserts:
|
||||
- equal: {path: 'stringData.DB_HOST', value: pg.svc}
|
||||
- equal: {path: 'stringData.DB_PORT', value: "5432"}
|
||||
- equal: {path: 'stringData.REDIS_HOST', value: rd.svc}
|
||||
- it: maps ALL legacy supersetNode.connections.* keys (deprecation path, no fail)
|
||||
set:
|
||||
cache:
|
||||
enabled: true
|
||||
supersetNode:
|
||||
connections:
|
||||
db_host: legacy-pg
|
||||
db_port: "5555"
|
||||
db_user: legacy-user
|
||||
db_pass: legacy-pass
|
||||
db_name: legacy-name
|
||||
redis_host: legacy-rd
|
||||
redis_port: "6380"
|
||||
redis_cache_db: "7"
|
||||
redis_celery_db: "8"
|
||||
redis_driver: rediss
|
||||
asserts:
|
||||
- equal: {path: 'stringData.DB_HOST', value: legacy-pg}
|
||||
- equal: {path: 'stringData.DB_PORT', value: "5555"}
|
||||
- equal: {path: 'stringData.DB_USER', value: legacy-user}
|
||||
- equal: {path: 'stringData.DB_PASS', value: legacy-pass}
|
||||
- equal: {path: 'stringData.DB_NAME', value: legacy-name}
|
||||
- equal: {path: 'stringData.REDIS_HOST', value: legacy-rd}
|
||||
- equal: {path: 'stringData.REDIS_PORT', value: "6380"}
|
||||
- equal: {path: 'stringData.REDIS_DB', value: "7"}
|
||||
- equal: {path: 'stringData.REDIS_CELERY_DB', value: "8"}
|
||||
- equal: {path: 'stringData.REDIS_PROTO', value: rediss}
|
||||
- it: new-schema values still win over legacy when both are set
|
||||
set:
|
||||
database: {host: new-host, port: 1234}
|
||||
supersetNode:
|
||||
connections: {db_host: old-host, db_port: "9999"}
|
||||
asserts:
|
||||
- equal: {path: 'stringData.DB_HOST', value: new-host}
|
||||
- equal: {path: 'stringData.DB_PORT', value: "1234"}
|
||||
- it: emits REDIS_DRIVER when cache.driver set
|
||||
set: {cache: {enabled: true, driver: "rediss+managed"}}
|
||||
asserts:
|
||||
- equal: {path: 'stringData.REDIS_DRIVER', value: "rediss+managed"}
|
||||
- it: omits redis vars when cache disabled
|
||||
set: {cache.enabled: false}
|
||||
asserts:
|
||||
- notExists: {path: 'stringData.REDIS_HOST'}
|
||||
28
helm/superset/tests/serviceaccount_test.yaml
Normal file
28
helm/superset/tests/serviceaccount_test.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
suite: serviceAccountName resolution
|
||||
templates:
|
||||
- serviceaccount.yaml
|
||||
tests:
|
||||
- it: uses serviceAccount.name when set
|
||||
set: {serviceAccount: {create: true, name: my-sa}}
|
||||
asserts:
|
||||
- equal: {path: 'metadata.name', value: my-sa}
|
||||
- it: falls back to legacy root serviceAccountName (deprecation path)
|
||||
set: {serviceAccount: {create: true}, serviceAccountName: legacy-sa}
|
||||
asserts:
|
||||
- equal: {path: 'metadata.name', value: legacy-sa}
|
||||
30
helm/superset/tests/values_defaults_test.yaml
Normal file
30
helm/superset/tests/values_defaults_test.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
suite: values defaults
|
||||
templates:
|
||||
- secret-env.yaml
|
||||
tests:
|
||||
- it: renders with new database defaults
|
||||
set:
|
||||
database.host: db.example
|
||||
cache.enabled: true
|
||||
asserts:
|
||||
- hasDocuments:
|
||||
count: 1
|
||||
- equal:
|
||||
path: 'stringData.DB_HOST'
|
||||
value: db.example
|
||||
140
helm/superset/values.schema.json
Normal file
140
helm/superset/values.schema.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"database": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"uri": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"driver": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"host": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"port": {
|
||||
"type": ["integer", "string", "null"]
|
||||
},
|
||||
"user": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"password": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"name": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"ssl": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"driver": {
|
||||
"type": "string"
|
||||
},
|
||||
"host": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"port": {
|
||||
"type": ["integer", "string", "null"]
|
||||
},
|
||||
"cacheDb": {
|
||||
"type": ["integer", "string", "null"]
|
||||
},
|
||||
"celeryDb": {
|
||||
"type": ["integer", "string", "null"]
|
||||
},
|
||||
"ssl": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cluster": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"databaseServiceName": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"redisServiceName": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"websocketServiceName": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"featureFlags": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"serviceAccount": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"create": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"annotations": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"globalPodAnnotations": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"image": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"pullPolicy": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,30 @@ nameOverride: ~
|
||||
# -- Provide a name to override the full names of resources
|
||||
fullnameOverride: ~
|
||||
|
||||
# -- Kubernetes cluster configuration
|
||||
# Used for constructing service URLs between chart components
|
||||
cluster:
|
||||
# -- Kubernetes cluster domain (default: .svc.cluster.local)
|
||||
# Override if using a custom cluster domain
|
||||
domain: ".svc.cluster.local"
|
||||
# -- Database service name (default: {{ .Release.Name }}-postgresql)
|
||||
# Override if using a different service name for the database
|
||||
databaseServiceName: ~
|
||||
# -- Redis service name (default: {{ .Release.Name }}-redis-headless)
|
||||
# Override if using a different service name for Redis
|
||||
redisServiceName: ~
|
||||
# -- WebSocket service name (default: {{ .Release.Name }}-ws)
|
||||
# Override if using a different service name for the WebSocket service
|
||||
websocketServiceName: ~
|
||||
|
||||
# -- Labels to be added to all resources
|
||||
extraLabels: {}
|
||||
|
||||
# -- Global pod annotations to be added to all pods
|
||||
# Use this to set annotations that apply to all Superset components
|
||||
# Component-specific podAnnotations will be merged with these global annotations
|
||||
globalPodAnnotations: {}
|
||||
|
||||
# -- User ID directive. This user must have enough permissions to run the bootstrap script
|
||||
# Running containers as root is not recommended in production. Change this to another UID - e.g. 1000 to be more secure
|
||||
runAsUser: 0
|
||||
@@ -46,6 +67,8 @@ serviceAccountName: ~
|
||||
serviceAccount:
|
||||
# -- Create custom service account for Superset. If create: true and serviceAccountName is not provided, `superset.fullname` will be used.
|
||||
create: false
|
||||
# -- Service account name to use (if not specified, defaults to release name + chart name)
|
||||
name: ""
|
||||
annotations: {}
|
||||
|
||||
# -- Install additional packages and do any other bootstrap configuration in this script
|
||||
@@ -244,6 +267,89 @@ hostAliases: []
|
||||
# - nodns.my.lan
|
||||
# ip: 18.27.36.45
|
||||
|
||||
# -- Database connection configuration for the Superset metadata database
|
||||
database:
|
||||
# -- Full database URI (overrides host/port/user/pass/name if set)
|
||||
# Example: "postgresql+psycopg2://user:pass@host:5432/dbname"
|
||||
uri: ~
|
||||
# -- Database driver used when uri is not set (default: postgresql+psycopg2 when unset;
|
||||
# legacy supersetNode.connections.db_type is honored when this is unset)
|
||||
driver: ~
|
||||
# -- Database host (default: {{ .Release.Name }}-postgresql)
|
||||
host: ~
|
||||
# -- Database port (default: 5432 when unset; legacy supersetNode.connections.db_port is honored)
|
||||
port: ~
|
||||
# -- Database user (default: superset, resolved via superset.db.user)
|
||||
user: ~
|
||||
# -- Database password (default: superset, resolved via superset.db.password)
|
||||
# ⚠️ CHANGE THIS for production
|
||||
password: ~
|
||||
# -- Database name (default: superset, resolved via superset.db.name)
|
||||
name: ~
|
||||
# -- Database SSL configuration
|
||||
ssl:
|
||||
enabled: false
|
||||
mode: require
|
||||
|
||||
# -- Redis cache configuration for Superset
|
||||
# Redis is optional but recommended for caching and Celery.
|
||||
# If redis.enabled (chart dependency) is true, defaults point to the chart's Redis instance.
|
||||
cache:
|
||||
# -- Enable Redis-based features (cache, Celery). Set to false to disable Redis usage entirely.
|
||||
enabled: true
|
||||
# -- Full Redis cache URL (overrides host/port/user/pass if set)
|
||||
cacheUrl: ~
|
||||
# -- Full Redis Celery URL (overrides host/port/user/pass if set)
|
||||
celeryUrl: ~
|
||||
# -- Redis host (default: {{ .Release.Name }}-redis-headless)
|
||||
host: ~
|
||||
# -- Redis port (default: 6379 when unset; legacy supersetNode.connections.redis_port is honored)
|
||||
port: ~
|
||||
# -- Redis user (optional, for Redis ACL)
|
||||
user: ""
|
||||
# -- Redis password
|
||||
password: ~
|
||||
# -- Redis database number for cache (default: 1 when unset; legacy redis_cache_db is honored)
|
||||
cacheDb: ~
|
||||
# -- Redis database number for Celery (default: 0 when unset; legacy redis_celery_db is honored)
|
||||
celeryDb: ~
|
||||
# -- Cache key prefix
|
||||
keyPrefix: "superset_"
|
||||
# -- Default cache timeout in seconds
|
||||
defaultTimeout: 86400
|
||||
# -- Results backend key prefix
|
||||
resultsBackendKeyPrefix: "superset_results"
|
||||
# -- Async queries configuration
|
||||
asyncQueries:
|
||||
keyPrefix: "qc-"
|
||||
timeout: 86400
|
||||
# -- Redis Sentinel configuration (optional)
|
||||
sentinel: ~
|
||||
# -- Redis SSL configuration
|
||||
ssl:
|
||||
enabled: false
|
||||
ssl_cert_reqs: required
|
||||
certfile: ~
|
||||
keyfile: ~
|
||||
ca_certs: ~
|
||||
# -- Custom Redis driver (e.g. TLS/managed variants); overrides the redis proto in URLs when set.
|
||||
# Ports the legacy supersetNode.connections.redis_driver escape hatch.
|
||||
driver: ""
|
||||
|
||||
# -- Superset configuration properties
|
||||
# Set any configuration property from superset/config.py here
|
||||
# See https://github.com/apache/superset/blob/master/superset/config.py for all available options
|
||||
config: {}
|
||||
# SECRET_KEY: "your-secret-key-here"
|
||||
# ROW_LIMIT: 50000
|
||||
# DEBUG: false
|
||||
|
||||
# -- Feature flags configuration
|
||||
# See https://github.com/apache/superset/blob/master/RESOURCES/FEATURE_FLAGS.md
|
||||
featureFlags: {}
|
||||
# ALERT_REPORTS: true
|
||||
# DASHBOARD_RBAC: true
|
||||
|
||||
# Superset node configuration
|
||||
supersetNode:
|
||||
replicas:
|
||||
@@ -270,28 +376,8 @@ supersetNode:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- ". {{ .Values.configMountPath }}/superset_bootstrap.sh; exec /usr/bin/run-server.sh"
|
||||
connections:
|
||||
# -- Change in case of bringing your own redis and then also set redis.enabled:false
|
||||
redis_host: "{{ .Release.Name }}-redis-headless"
|
||||
redis_port: "6379"
|
||||
redis_user: ""
|
||||
# redis_password: superset
|
||||
redis_cache_db: "1"
|
||||
redis_celery_db: "0"
|
||||
# Or SSL port is usually 6380
|
||||
# Update following for using Redis with SSL
|
||||
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"
|
||||
db_host: "{{ .Release.Name }}-postgresql"
|
||||
db_port: "5432"
|
||||
db_user: superset
|
||||
db_pass: superset
|
||||
db_name: superset
|
||||
# DEPRECATED: use database.* and cache.* instead (still honored if set). See UPGRADING.md
|
||||
connections: {}
|
||||
env: {}
|
||||
# -- If true, forces deployment to reload on each upgrade
|
||||
forceReload: false
|
||||
@@ -508,6 +594,17 @@ supersetWorker:
|
||||
startupProbe: {}
|
||||
# -- No startup/readiness probes by default since we don't really care about its startup time (it doesn't serve traffic)
|
||||
readinessProbe: {}
|
||||
# -- Celery worker file-based health check (worker writes readiness/liveness files via signals;
|
||||
# point supersetWorker.readinessProbe/livenessProbe at these files to use them)
|
||||
healthCheck:
|
||||
# -- Enable the file-based Celery worker health check
|
||||
enabled: false
|
||||
# -- Readiness file (created when the worker is ready, removed on shutdown)
|
||||
readinessFile: "/tmp/celery_worker_ready"
|
||||
# -- Liveness file (touched periodically by a heartbeat thread)
|
||||
livenessFile: "/tmp/celery_worker_alive"
|
||||
# -- Seconds between liveness heartbeats
|
||||
livenessHeartbeatInterval: 10
|
||||
# -- Set priorityClassName for supersetWorker pods
|
||||
priorityClassName: ~
|
||||
|
||||
@@ -870,8 +967,11 @@ init:
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "128Mi"
|
||||
# -- A Superset init script
|
||||
# @default -- a script to create admin user and initialize roles
|
||||
# -- DEPRECATED: this field is no longer used by the chart.
|
||||
# The init script is rendered entirely from the internal `superset.initScript` template
|
||||
# (which runs `superset db upgrade`, `superset init`, admin creation, and examples).
|
||||
# Any customization placed here is silently ignored. See UPGRADING.md.
|
||||
# @default -- unused; kept for backwards-compatibility only
|
||||
initscript: |-
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
@@ -43,7 +43,7 @@ dependencies = [
|
||||
# ``google-auth`` 2.53+ dropped it, so Superset must declare it
|
||||
# explicitly to keep fresh ``pip install apache-superset`` working
|
||||
# without the ``base.txt`` lock file (#40962).
|
||||
"cachetools>=6.2.1, <7",
|
||||
"cachetools>=7.1.4, <8",
|
||||
"celery>=5.6.3, <6.0.0",
|
||||
"click>=8.4.0",
|
||||
"click-option-group",
|
||||
@@ -67,7 +67,7 @@ dependencies = [
|
||||
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
"holidays>=0.45, <1",
|
||||
"holidays>=0.99, <1",
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.8.0, <2",
|
||||
@@ -106,11 +106,11 @@ dependencies = [
|
||||
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.1",
|
||||
"slack_sdk>=3.42.0, <4",
|
||||
"slack_sdk>=3.43.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.8.0, <31",
|
||||
"sqlglot>=30.12.0, <31",
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.10.0, <1.0",
|
||||
"typing-extensions>=4, <5",
|
||||
@@ -161,11 +161,11 @@ fastmcp = [
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
"tiktoken>=0.13.0,<1.0",
|
||||
]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
|
||||
firebird = ["sqlalchemy-firebird>=0.8.0, <2.2"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
|
||||
gevent = ["gevent>=26.4.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
|
||||
hana = ["hdbcli==2.28.21", "sqlalchemy_hana==0.4.0"]
|
||||
hana = ["hdbcli==2.28.21", "sqlalchemy_hana==3.0.3"]
|
||||
hive = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
"pyhive[hive_pure_sasl]>=0.7.0",
|
||||
@@ -173,7 +173,7 @@ hive = [
|
||||
"thrift>=0.23.0, <1.0.0",
|
||||
"thrift_sasl>=0.4.3, < 1.0.0",
|
||||
]
|
||||
impala = ["impyla>0.16.2, <0.23"]
|
||||
impala = ["impyla>=0.24.0, <0.25"]
|
||||
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
|
||||
kylin = ["kylinpy>=2.8.4, <2.9"]
|
||||
mssql = ["pymssql>=2.3.13, <3"]
|
||||
@@ -199,7 +199,7 @@ risingwave = ["sqlalchemy-risingwave"]
|
||||
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
|
||||
singlestore = ["sqlalchemy-singlestoredb>=1.2.1, <2"]
|
||||
snowflake = ["snowflake-sqlalchemy>=1.10.2, <2"]
|
||||
sqlite = ["syntaqlite>=0.1.0,<0.5.0"]
|
||||
sqlite = ["syntaqlite>=0.6.0,<0.7.0"]
|
||||
spark = [
|
||||
"pyhive[hive]>=0.6.5;python_version<'3.11'",
|
||||
"pyhive[hive_pure_sasl]>=0.7",
|
||||
@@ -244,7 +244,7 @@ development = [
|
||||
"ruff",
|
||||
"sqloxide",
|
||||
"statsd",
|
||||
"syntaqlite>=0.4.2,<0.5.0",
|
||||
"syntaqlite>=0.6.0,<0.7.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -35,13 +35,11 @@ filterwarnings =
|
||||
# error:"SqlMetric" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:"TableColumn" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:"TaggedObject" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:The ``as_declarative\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:The autoload parameter is deprecated:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:The connection.execute\(\) method:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:The `database` package is deprecated:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
|
||||
# error:The Engine.execute\(\) method is considered legacy: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:"User" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
|
||||
|
||||
@@ -44,7 +44,7 @@ cachelib==0.13.0
|
||||
# via
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==6.2.1
|
||||
cachetools==7.1.4
|
||||
# via apache-superset (pyproject.toml)
|
||||
cattrs==25.1.1
|
||||
# via requests-cache
|
||||
@@ -175,7 +175,7 @@ h11==0.16.0
|
||||
# via wsproto
|
||||
hashids==1.3.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
holidays==0.82
|
||||
holidays==0.100
|
||||
# via apache-superset (pyproject.toml)
|
||||
humanize==4.12.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -390,7 +390,7 @@ six==1.17.0
|
||||
# python-dateutil
|
||||
# rfc3339-validator
|
||||
# wtforms-json
|
||||
slack-sdk==3.42.0
|
||||
slack-sdk==3.43.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
sniffio==1.3.1
|
||||
# via trio
|
||||
@@ -414,7 +414,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.8.0
|
||||
sqlglot==30.12.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
|
||||
@@ -97,7 +97,7 @@ cachelib==0.13.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==6.2.1
|
||||
cachetools==7.1.4
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -407,7 +407,7 @@ hashids==1.3.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
holidays==0.82
|
||||
holidays==0.100
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -959,7 +959,7 @@ six==1.17.0
|
||||
# python-dateutil
|
||||
# rfc3339-validator
|
||||
# wtforms-json
|
||||
slack-sdk==3.42.0
|
||||
slack-sdk==3.43.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -998,7 +998,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.8.0
|
||||
sqlglot==30.12.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -1017,7 +1017,7 @@ starlette==1.3.1
|
||||
# mcp
|
||||
statsd==4.0.1
|
||||
# via apache-superset
|
||||
syntaqlite==0.4.2
|
||||
syntaqlite==0.6.0
|
||||
# via apache-superset
|
||||
tabulate==0.10.0
|
||||
# via
|
||||
|
||||
@@ -153,10 +153,11 @@ def main( # noqa: C901
|
||||
)
|
||||
|
||||
print(f"Migration goes from {down_revision} to {revision}")
|
||||
current_revision = db.engine.execute(
|
||||
text("SELECT version_num FROM alembic_version")
|
||||
).scalar()
|
||||
print(f"Current version of the DB is {current_revision}")
|
||||
with db.engine.connect() as conn:
|
||||
current_revision = conn.execute(
|
||||
text("SELECT version_num FROM alembic_version")
|
||||
).scalar()
|
||||
print(f"Current version of the DB is {current_revision}")
|
||||
|
||||
if current_revision != down_revision:
|
||||
if not force:
|
||||
|
||||
49
scripts/stylelint.sh
Executable file
49
scripts/stylelint.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 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.
|
||||
|
||||
set -e
|
||||
|
||||
script_dir="$(dirname "$(realpath "$0")")"
|
||||
root_dir="$(dirname "$script_dir")"
|
||||
frontend_dir=superset-frontend
|
||||
|
||||
if [[ ! -d "$root_dir/$frontend_dir" ]]; then
|
||||
echo "Error: $frontend_dir directory not found in $root_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$root_dir/$frontend_dir"
|
||||
|
||||
# Filter files to only include JS/TS files and remove the frontend dir prefix
|
||||
js_ts_files=()
|
||||
for file in "$@"; do
|
||||
# Remove superset-frontend/ prefix if present
|
||||
cleaned_file="${file#$frontend_dir/}"
|
||||
|
||||
# Only include JS/TS files (Emotion styles live in these)
|
||||
if [[ "$cleaned_file" =~ \.(js|jsx|ts|tsx)$ ]]; then
|
||||
js_ts_files+=("$cleaned_file")
|
||||
fi
|
||||
done
|
||||
|
||||
# Only run if we have JS/TS files to lint. --allow-empty-input keeps the hook
|
||||
# green when every passed file is covered by .stylelintignore.
|
||||
if [ ${#js_ts_files[@]} -gt 0 ]; then
|
||||
npx stylelint --allow-empty-input "${js_ts_files[@]}"
|
||||
else
|
||||
echo "No JavaScript/TypeScript files to lint for styles"
|
||||
fi
|
||||
@@ -92,4 +92,33 @@ do
|
||||
fi
|
||||
done
|
||||
|
||||
# A few UI labels ("% calculation" etc.) parse as accidentally-valid
|
||||
# %-format directives (a space flag plus a conversion character), so
|
||||
# babel auto-flags them python-format on every write and msgfmt then
|
||||
# fatals on their translations. The app never %-formats them; strip the
|
||||
# flag AFTER the update pass (babel re-adds it during the update, so
|
||||
# this must run last). Line-targeted so canonical wrapping is untouched.
|
||||
python3 - <<'PYEOF'
|
||||
import glob
|
||||
|
||||
TARGETS = {'msgid "% calculation"\n', 'msgid "% of parent"\n', 'msgid "% of total"\n'}
|
||||
paths = ["superset/translations/messages.pot"] + sorted(
|
||||
glob.glob("superset/translations/*/LC_MESSAGES/messages.po")
|
||||
)
|
||||
for path in paths:
|
||||
lines = open(path, encoding="utf-8").readlines()
|
||||
changed = False
|
||||
for i, line in enumerate(lines):
|
||||
if line in TARGETS and i > 0 and lines[i - 1].startswith("#,"):
|
||||
tokens = [t.strip() for t in lines[i - 1][2:].split(",")]
|
||||
if "python-format" in tokens:
|
||||
tokens = [t for t in tokens if t != "python-format"]
|
||||
if "no-python-format" not in tokens:
|
||||
tokens.append("no-python-format")
|
||||
lines[i - 1] = "#, " + ", ".join(tokens) + "\n"
|
||||
changed = True
|
||||
if changed:
|
||||
open(path, "w", encoding="utf-8").writelines(lines)
|
||||
PYEOF
|
||||
|
||||
cd $CURRENT_DIR
|
||||
|
||||
@@ -22,7 +22,7 @@ import thunk from 'redux-thunk';
|
||||
import { Provider } from 'react-redux';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import { Global } from '@emotion/react';
|
||||
import { App, Layout, Space, Content } from 'antd';
|
||||
import { App, Layout } from 'antd';
|
||||
|
||||
import 'src/theme.ts';
|
||||
import './storybook.css';
|
||||
|
||||
9
superset-frontend/.stylelintignore
Normal file
9
superset-frontend/.stylelintignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Files whose Emotion template literals nest a `css` tag inside an
|
||||
# interpolation (e.g. styled(X)`${props => css`...`}`) or otherwise use a
|
||||
# structure that postcss-styled-syntax cannot parse. Stylelint reports a
|
||||
# CssSyntaxError on these rather than a real lint finding, so they are
|
||||
# excluded until the syntax gains nested-template support.
|
||||
src/components/ListView/Filters/Base.ts
|
||||
src/features/databases/DatabaseModal/styles.ts
|
||||
src/features/databases/UploadDataModel/styles.ts
|
||||
packages/superset-ui-core/src/components/Typography/index.tsx
|
||||
@@ -471,19 +471,19 @@ export function applyAdvancedTimeRangeFilterOnDashboard(
|
||||
endRange?: string,
|
||||
) {
|
||||
cy.get('.control-label').contains('Range type').should('be.visible');
|
||||
cy.get('.ant-popover-content .ant-select-selector')
|
||||
cy.get('.ant-popover-content .ant-select-content')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
cy.get(`[label="Advanced"]`).should('be.visible').click();
|
||||
cy.get('.section-title').contains('Advanced Time Range').should('be.visible');
|
||||
if (startRange) {
|
||||
cy.get('.ant-popover-inner-content')
|
||||
cy.get('.ant-popover-content')
|
||||
.find('[class^=ant-input]')
|
||||
.first()
|
||||
.type(`${startRange}`);
|
||||
}
|
||||
if (endRange) {
|
||||
cy.get('.ant-popover-inner-content')
|
||||
cy.get('.ant-popover-content')
|
||||
.find('[class^=ant-input]')
|
||||
.last()
|
||||
.type(`${endRange}`);
|
||||
@@ -514,15 +514,14 @@ export function inputNativeFilterDefaultValue(
|
||||
)
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('.ant-select-selection-search-input').type(
|
||||
`${defaultValue}{enter}`,
|
||||
{ force: true },
|
||||
);
|
||||
cy.get('.ant-select-input').type(`${defaultValue}{enter}`, {
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
cy.getBySel('default-input').within(() => {
|
||||
cy.get('.ant-select-selection-search-input').click();
|
||||
cy.get('.ant-select-input').click();
|
||||
cy.get('.ant-select-item-option-content').contains(defaultValue).click();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,9 +53,7 @@ export function saveChartToDashboard(chartName: string, dashboardName: string) {
|
||||
.then($modal => {
|
||||
cy.wait(500);
|
||||
cy.wrap($modal)
|
||||
.find(
|
||||
'.ant-select-selection-search-input[aria-label*="Select a dashboard"]',
|
||||
)
|
||||
.find('.ant-select-input[aria-label*="Select a dashboard"]')
|
||||
.type(dashboardName, { force: true });
|
||||
|
||||
cy.get(`.ant-select-item-option[title="${dashboardName}"]`).click();
|
||||
|
||||
@@ -52,7 +52,7 @@ export const securityAccess = {
|
||||
};
|
||||
export const homePage = {
|
||||
homeSection: {
|
||||
sectionArea: '.ant-collapse-content-box',
|
||||
sectionArea: '.ant-collapse-body',
|
||||
sectionElement: '.ant-card-meta-title',
|
||||
},
|
||||
sections: {
|
||||
@@ -91,7 +91,7 @@ export const databasesPage = {
|
||||
preferredBlockSheets: '.preferred > :nth-child(6)',
|
||||
supportedDatabasesText: '.control-label',
|
||||
orChoose: '.available-label',
|
||||
dbDropdown: '[class="ant-select-selection-search-input"]',
|
||||
dbDropdown: '.ant-select-input',
|
||||
dbDropdownMenu: '.rc-virtual-list-holder-inner',
|
||||
dbDropdownMenuItem: '[class="ant-select-item-option-content"]',
|
||||
infoAlert: '.ant-alert',
|
||||
@@ -277,7 +277,7 @@ export const chartListView = {
|
||||
header: {
|
||||
cardView: '[aria-label="card-view"]',
|
||||
listView: '[aria-label="list-view"]',
|
||||
sort: '[class="ant-select-selection-search-input"][aria-label="Sort"]',
|
||||
sort: '.ant-select-input[aria-label="Sort"]',
|
||||
sortRecentlyModifiedMenuOption: '[label="Recently modified"]',
|
||||
sortAlphabeticalMenuOption: '[label="Alphabetical"]',
|
||||
sortDropdown: '.Select__menu',
|
||||
@@ -338,20 +338,20 @@ export const nativeFilters = {
|
||||
filtersPanel: {
|
||||
filterName: dataTestLocator('filters-config-modal__name-input'),
|
||||
datasetName: dataTestLocator('filters-config-modal__datasource-input'),
|
||||
filterInfoInput: '.ant-select-selection-search-input',
|
||||
filterInfoInput: '.ant-select-input',
|
||||
inputDropdown: '.ant-select-item-option-content',
|
||||
columnEmptyInput: '.ant-select-selection-placeholder',
|
||||
columnEmptyInput: '.ant-select-placeholder',
|
||||
filterTypeInput: dataTestLocator('filters-config-modal__filter-type'),
|
||||
fieldInput: dataTestLocator('field-input'),
|
||||
filterTypeItem: '.ant-select-selection-item',
|
||||
filterTypeItem: '.ant-select-content-has-value, .ant-select-selection-item',
|
||||
filterGear: dataTestLocator('filterbar-orientation-icon'),
|
||||
},
|
||||
filterFromDashboardView: {
|
||||
filterValueInput: '[class="ant-select-selection-search-input"]',
|
||||
filterValueInput: '.ant-select-input',
|
||||
expand: dataTestLocator('filter-bar__expand-button'),
|
||||
collapse: dataTestLocator('filter-bar__collapse-button'),
|
||||
filterName: dataTestLocator('filter-control-name'),
|
||||
filterContent: '.ant-select-selection-item',
|
||||
filterContent: '.ant-select-content-has-value, .ant-select-selection-item',
|
||||
createFilterButton: dataTestLocator('filter-bar__create-filter'),
|
||||
timeRangeFilterContent: dataTestLocator('time-range-trigger'),
|
||||
},
|
||||
@@ -365,8 +365,8 @@ export const nativeFilters = {
|
||||
checkedCheckbox: '.ant-checkbox-wrapper-checked',
|
||||
infoTooltip: '[aria-label="Show info tooltip"]',
|
||||
parentFilterInput: dataTestLocator('parent-filter-input'),
|
||||
filterPlaceholder: '.ant-select-selection-placeholder',
|
||||
collapsedSectionContainer: '[class="ant-collapse-content-box"]',
|
||||
filterPlaceholder: '.ant-select-placeholder',
|
||||
collapsedSectionContainer: '[class="ant-collapse-body"]',
|
||||
},
|
||||
filtersList: {
|
||||
list: '.ant-tabs-nav-list',
|
||||
@@ -376,7 +376,7 @@ export const nativeFilters = {
|
||||
removeIcon: '[aria-label="delete"]',
|
||||
},
|
||||
filterItem: dataTestLocator('form-item-value'),
|
||||
filterItemDropdown: '.ant-select-selection-search',
|
||||
filterItemDropdown: '.ant-select-input',
|
||||
applyFilter: dataTestLocator('filter-bar__apply-button'),
|
||||
defaultInput: dataTestLocator('default-input'),
|
||||
filterIcon: dataTestLocator('filter-icon'),
|
||||
@@ -484,7 +484,7 @@ export const exploreView = {
|
||||
saveModal: {
|
||||
modal: '.ant-modal-content',
|
||||
chartNameInput: dataTestLocator('new-chart-name'),
|
||||
dashboardNameInput: '.ant-select-selection-search-input',
|
||||
dashboardNameInput: '.ant-select-input',
|
||||
addToDashboardInput: dataTestLocator(
|
||||
'save-chart-modal-select-dashboard-form',
|
||||
),
|
||||
@@ -495,7 +495,7 @@ export const exploreView = {
|
||||
},
|
||||
controlPanel: {
|
||||
panel: dataTestLocator('control-tabs'),
|
||||
categoryArea: '.ant-collapse-content-box',
|
||||
categoryArea: '.ant-collapse-body',
|
||||
dragField: dataTestLocator('datasource'),
|
||||
metricsField: dataTestLocator('metrics'),
|
||||
optionField: dataTestLocator('option-label'),
|
||||
@@ -594,7 +594,7 @@ export const exploreView = {
|
||||
},
|
||||
};
|
||||
export const createChartView = {
|
||||
chooseDatasetInput: '.ant-select-selection-search-input',
|
||||
chooseDatasetInput: '.ant-select-input',
|
||||
chooseDatasetOption: '.ant-select-item-option-content',
|
||||
chooseDatasetList: '.rc-virtual-list-holder-inner',
|
||||
tableVizType: "[alt='Table']",
|
||||
@@ -641,7 +641,7 @@ export const dashboardView = {
|
||||
secondTabSalesDashboard: dataTestLocator('dragdroppable-object'),
|
||||
},
|
||||
timeRangeModal: {
|
||||
rangeTypeField: '.ant-select-selection-item',
|
||||
rangeTypeField: '.ant-select-content-has-value, .ant-select-selection-item',
|
||||
startTimeInputNumber: '.ant-input-number-input',
|
||||
datePicker: '.ant-picker-input',
|
||||
applyButton: dataTestLocator('date-filter-control__apply-button'),
|
||||
|
||||
@@ -75,7 +75,7 @@ module.exports = {
|
||||
// @ant-design/colors and @ant-design/fast-color are allowed through because
|
||||
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
|
||||
// from its CJS output, so babel-jest must transform those files.
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact)',
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
@@ -52,10 +52,6 @@
|
||||
// require-default-props, sort-comp, static-property-placement
|
||||
// (prefer-stateless-function / function-component-definition
|
||||
// are represented by react/prefer-function-component below)
|
||||
// jsx-a11y: interactive-supports-focus,
|
||||
// no-interactive-element-to-noninteractive-role,
|
||||
// no-noninteractive-element-interactions,
|
||||
// no-noninteractive-element-to-interactive-role
|
||||
// typescript: naming-convention
|
||||
// unicorn: prevent-abbreviations
|
||||
|
||||
@@ -196,6 +192,7 @@
|
||||
|
||||
// === JSX-a11y rules ===
|
||||
"jsx-a11y/alt-text": "error",
|
||||
"jsx-a11y/anchor-ambiguous-text": "error",
|
||||
"jsx-a11y/anchor-has-content": "error",
|
||||
"jsx-a11y/anchor-is-valid": "error",
|
||||
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
|
||||
@@ -203,20 +200,31 @@
|
||||
"jsx-a11y/aria-proptypes": "error",
|
||||
"jsx-a11y/aria-role": ["error", { "ignoreNonDOM": false }],
|
||||
"jsx-a11y/aria-unsupported-elements": "error",
|
||||
"jsx-a11y/autocomplete-valid": "error",
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/control-has-associated-label": "error",
|
||||
"jsx-a11y/heading-has-content": "error",
|
||||
"jsx-a11y/html-has-lang": "error",
|
||||
"jsx-a11y/iframe-has-title": "error",
|
||||
"jsx-a11y/img-redundant-alt": "error",
|
||||
"jsx-a11y/interactive-supports-focus": "error",
|
||||
"jsx-a11y/label-has-associated-control": "error",
|
||||
"jsx-a11y/lang": "error",
|
||||
"jsx-a11y/media-has-caption": "error",
|
||||
"jsx-a11y/mouse-events-have-key-events": "off",
|
||||
"jsx-a11y/mouse-events-have-key-events": "error",
|
||||
"jsx-a11y/no-access-key": "error",
|
||||
"jsx-a11y/no-aria-hidden-on-focusable": "error",
|
||||
"jsx-a11y/no-autofocus": ["error", { "ignoreNonDOM": true }],
|
||||
"jsx-a11y/no-distracting-elements": "error",
|
||||
"jsx-a11y/no-interactive-element-to-noninteractive-role": "error",
|
||||
"jsx-a11y/no-noninteractive-element-interactions": "error",
|
||||
"jsx-a11y/no-noninteractive-element-to-interactive-role": "error",
|
||||
"jsx-a11y/no-noninteractive-tabindex": "error",
|
||||
"jsx-a11y/no-redundant-roles": "error",
|
||||
// TODO: Graduate to "error" — remaining violations are event-boundary
|
||||
// wrappers (divs/spans whose handlers only stopPropagation) and test
|
||||
// scaffolding where adding a role is semantically wrong. Needs a
|
||||
// dedicated pass; see PR enabling the other jsx-a11y interactive rules.
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"jsx-a11y/role-has-required-aria-props": "error",
|
||||
"jsx-a11y/role-supports-aria-props": "error",
|
||||
@@ -232,7 +240,14 @@
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrors": "none"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/prefer-optional-chain": "error",
|
||||
|
||||
// === Unicorn rules (bonus coverage) ===
|
||||
@@ -282,6 +297,16 @@
|
||||
"rules": {
|
||||
"jsx-a11y/no-redundant-roles": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// The pivot table intentionally exposes `role="grid"` on its <table>
|
||||
// for interactive grid semantics (asserted by tableRenders.test.tsx).
|
||||
"files": [
|
||||
"plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"jsx-a11y/no-noninteractive-element-to-interactive-role": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": [
|
||||
|
||||
2702
superset-frontend/package-lock.json
generated
2702
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,9 @@
|
||||
"lint:all": "npx oxlint --config oxlint.json && npm run type",
|
||||
"lint-fix": "npx oxlint --config oxlint.json --fix --quiet",
|
||||
"lint-fix:all": "npx oxlint --config oxlint.json --fix",
|
||||
"lint:full": "npm run lint && npm run check:custom-rules",
|
||||
"lint:full": "npm run lint && npm run check:custom-rules && npm run stylelint",
|
||||
"stylelint": "stylelint \"{src,packages/*/src,plugins/*/src}/**/*.{ts,tsx}\"",
|
||||
"stylelint-fix": "stylelint --fix \"{src,packages/*/src,plugins/*/src}/**/*.{ts,tsx}\"",
|
||||
"check:custom-rules": "node scripts/check-custom-rules.js",
|
||||
"check:storybook-coverage": "node scripts/check-storybook-coverage.js",
|
||||
"ensure-oxc": "echo 'OXC linter is ready' && npx oxlint --version",
|
||||
@@ -128,10 +130,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/antd": "^5.24.13",
|
||||
"@rjsf/core": "^5.24.13",
|
||||
"@rjsf/utils": "^5.24.3",
|
||||
"@rjsf/validator-ajv8": "^5.24.13",
|
||||
"@rjsf/core": "^6.6.2",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.6.2",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -167,7 +168,7 @@
|
||||
"@visx/xychart": "^4.0.0",
|
||||
"ag-grid-community": "36.0.0",
|
||||
"ag-grid-react": "36.0.0",
|
||||
"antd": "^5.26.0",
|
||||
"antd": "^6.0.0",
|
||||
"chrono-node": "^2.9.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
@@ -186,7 +187,7 @@
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^10.9.0",
|
||||
"immer": "^11.1.9",
|
||||
"immer": "^11.1.11",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
@@ -205,7 +206,7 @@
|
||||
"query-string": "9.4.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.3.0",
|
||||
"react-arborist": "^3.12.0",
|
||||
"react-arborist": "^3.13.2",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -305,7 +306,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.10.41",
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -346,6 +347,7 @@
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.72.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"prettier": "3.9.4",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
@@ -358,15 +360,16 @@
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.6",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.5",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.108.3",
|
||||
"webpack": "^5.108.4",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-dev-server": "^5.2.5",
|
||||
@@ -387,6 +390,9 @@
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "$uuid",
|
||||
"@great-expectations/jsonforms-antd-renderers": {
|
||||
"antd": "$antd"
|
||||
},
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"esbuild": "^0.28.1",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"react-loadable": "^5.5.0",
|
||||
"tinycolor2": "*",
|
||||
"lodash": "^4.18.1",
|
||||
"antd": "^5.26.0",
|
||||
"antd": "^6.0.0",
|
||||
"jed": "^1.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -38,7 +38,7 @@ export const GlobalStyles = () => {
|
||||
<Global
|
||||
key={`global-${theme.colorLink}`}
|
||||
styles={css`
|
||||
// SPA
|
||||
/* SPA */
|
||||
html {
|
||||
color-scheme: ${isDark ? 'dark' : 'light'};
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export const GlobalStyles = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Overriding bootstrap styles
|
||||
/* Overriding bootstrap styles */
|
||||
#app {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
@@ -105,8 +105,18 @@ export const GlobalStyles = () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Override geostyler CSS that hides AntD ColorPicker alpha input
|
||||
// See: https://github.com/apache/superset/issues/34721
|
||||
/* antd 6 removed the Tag's default trailing margin (v5 shipped */
|
||||
/* margin-inline-end: 8px on every tag) in favor of parents spacing */
|
||||
/* tags via flex/Space gaps. The app's layouts predate that and rely */
|
||||
/* on the v5 default (e.g. the dashboard header's Published tag), */
|
||||
/* so restore it for visual parity. Remove once Tag-adjacent layouts */
|
||||
/* declare their own gaps. */
|
||||
.ant-tag {
|
||||
margin-inline-end: ${theme.marginXS}px;
|
||||
}
|
||||
|
||||
/* Override geostyler CSS that hides AntD ColorPicker alpha input */
|
||||
/* See: https://github.com/apache/superset/issues/34721 */
|
||||
.ant-color-picker .ant-color-picker-alpha-input {
|
||||
display: block;
|
||||
}
|
||||
@@ -117,7 +127,7 @@ export const GlobalStyles = () => {
|
||||
}
|
||||
|
||||
.superset-explore-popover.ant-popover
|
||||
.ant-popover-inner:has(.ant-popover-title) {
|
||||
.ant-popover-container:has(.ant-popover-title) {
|
||||
padding-top: 0;
|
||||
}
|
||||
.superset-explore-popover.ant-popover .ant-popover-title {
|
||||
@@ -126,7 +136,7 @@ export const GlobalStyles = () => {
|
||||
line-height: 1;
|
||||
}
|
||||
.superset-explore-popover.ant-popover
|
||||
.ant-popover-inner:has(.ant-popover-title)
|
||||
.ant-popover-container:has(.ant-popover-title)
|
||||
.ant-tabs-tab {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,34 @@
|
||||
*/
|
||||
import { theme as antdThemeImport } from 'antd';
|
||||
import { Theme } from './Theme';
|
||||
import { AnyThemeConfig, ThemeAlgorithm } from './types';
|
||||
import { AnyThemeConfig, ThemeAlgorithm, allowedAntdTokens } from './types';
|
||||
|
||||
/**
|
||||
* A fixed, Superset-representative base theme used by the characterization
|
||||
* tests below. Mirrors the real THEME_DEFAULT token set so the snapshots are
|
||||
* stable and meaningful. Do NOT churn this casually — it is the baseline the
|
||||
* Ant Design v6 upgrade is measured against.
|
||||
*/
|
||||
const SUPERSET_BASE_THEME: AnyThemeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#2893B3',
|
||||
colorError: '#e04355',
|
||||
colorWarning: '#fcc700',
|
||||
colorSuccess: '#5ac189',
|
||||
colorInfo: '#66bcfe',
|
||||
fontFamily: "'Inter', Helvetica, Arial",
|
||||
fontFamilyCode: "'IBM Plex Mono', 'Courier New', monospace",
|
||||
},
|
||||
};
|
||||
|
||||
/** Collect the computed value of every allow-listed antd token, sorted. */
|
||||
function pickAllowedTokens(theme: Theme): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
[...allowedAntdTokens].sort().forEach(key => {
|
||||
out[key] = (theme.theme as unknown as Record<string, unknown>)[key];
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Mock emotion's cache to avoid actual DOM operations
|
||||
jest.mock('@emotion/cache', () => ({
|
||||
@@ -338,10 +365,10 @@ test('Theme edge cases correctly applies base theme tokens in dark mode', () =>
|
||||
algorithm: antdThemeImport.defaultAlgorithm,
|
||||
};
|
||||
|
||||
const baseThemeDark: AnyThemeConfig = {
|
||||
const baseThemeDark = {
|
||||
...baseTheme,
|
||||
algorithm: antdThemeImport.darkAlgorithm,
|
||||
};
|
||||
} as AnyThemeConfig;
|
||||
|
||||
// Simulate light mode with base theme
|
||||
const lightTheme = Theme.fromConfig({}, baseTheme);
|
||||
@@ -473,10 +500,10 @@ test('Theme base theme integration handles base theme with dark algorithm correc
|
||||
},
|
||||
};
|
||||
|
||||
const baseThemeDark: AnyThemeConfig = {
|
||||
const baseThemeDark = {
|
||||
...baseTheme,
|
||||
algorithm: antdThemeImport.darkAlgorithm,
|
||||
};
|
||||
} as AnyThemeConfig;
|
||||
|
||||
const userDarkTheme: AnyThemeConfig = {
|
||||
algorithm: antdThemeImport.darkAlgorithm,
|
||||
@@ -525,7 +552,7 @@ test('Theme base theme integration works with real-world Superset base theme con
|
||||
const darkTheme = Theme.fromConfig(themeDark, {
|
||||
...supersetBaseTheme,
|
||||
algorithm: antdThemeImport.darkAlgorithm,
|
||||
});
|
||||
} as AnyThemeConfig);
|
||||
expect(darkTheme.theme.fontFamily).toBe("'Inter', Helvetica, Arial");
|
||||
|
||||
const darkSerialized = darkTheme.toSerializedConfig();
|
||||
@@ -604,7 +631,9 @@ test('Theme base theme integration handles cssVar, hashed and inherit properties
|
||||
|
||||
// User properties override/add to base
|
||||
expect(serialized.inherit).toBe(true);
|
||||
expect(serialized.cssVar).toBe(true);
|
||||
// Ant Design v6 dropped boolean `cssVar`; Superset normalizes `true` to the
|
||||
// object form `{}` at the antd boundary (CSS variables are on by default).
|
||||
expect(serialized.cssVar).toEqual({});
|
||||
expect(serialized.hashed).toBe(false);
|
||||
|
||||
// Tokens are still merged
|
||||
@@ -909,3 +938,81 @@ test('colorLink is preserved in setConfig when explicitly set', () => {
|
||||
expect(theme.theme.colorPrimary).toBe('#f759ab');
|
||||
expect(theme.theme.colorLink).toBe('#ff0000');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Characterization tests — theming safety net for the Ant Design v6 upgrade.
|
||||
//
|
||||
// These lock the bridge between Superset's theme and Ant Design's design
|
||||
// tokens (getDesignToken -> allowedAntdTokens -> SupersetTheme). When antd
|
||||
// renames or removes a token, the completeness tests fail loudly instead of
|
||||
// letting a token silently resolve to `undefined`, and the snapshots make any
|
||||
// value drift reviewable. See packages/superset-core/src/theme/types.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('every allowed antd token resolves to a defined value in light mode', () => {
|
||||
const theme = Theme.fromConfig(
|
||||
{ algorithm: antdThemeImport.defaultAlgorithm },
|
||||
SUPERSET_BASE_THEME,
|
||||
);
|
||||
|
||||
const missing = allowedAntdTokens.filter(
|
||||
key =>
|
||||
(theme.theme as unknown as Record<string, unknown>)[key] === undefined,
|
||||
);
|
||||
|
||||
// A non-empty list means antd dropped/renamed a token we still allow-list.
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('every allowed antd token resolves to a defined value in dark mode', () => {
|
||||
const theme = Theme.fromConfig(
|
||||
{ algorithm: antdThemeImport.darkAlgorithm },
|
||||
SUPERSET_BASE_THEME,
|
||||
);
|
||||
|
||||
const missing = allowedAntdTokens.filter(
|
||||
key =>
|
||||
(theme.theme as unknown as Record<string, unknown>)[key] === undefined,
|
||||
);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('allowed antd token key set is stable', () => {
|
||||
// Locks the *set* of tokens Superset exposes (independent of their values),
|
||||
// so additions/removals to allowedAntdTokens are an explicit, reviewed diff.
|
||||
expect([...allowedAntdTokens].sort()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('computed light-mode token values match snapshot', () => {
|
||||
const theme = Theme.fromConfig(
|
||||
{ algorithm: antdThemeImport.defaultAlgorithm },
|
||||
SUPERSET_BASE_THEME,
|
||||
);
|
||||
|
||||
expect(pickAllowedTokens(theme)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('computed dark-mode token values match snapshot', () => {
|
||||
const theme = Theme.fromConfig(
|
||||
{ algorithm: antdThemeImport.darkAlgorithm },
|
||||
SUPERSET_BASE_THEME,
|
||||
);
|
||||
|
||||
expect(pickAllowedTokens(theme)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('dark mode actually diverges from light mode for background tokens', () => {
|
||||
const light = Theme.fromConfig(
|
||||
{ algorithm: antdThemeImport.defaultAlgorithm },
|
||||
SUPERSET_BASE_THEME,
|
||||
);
|
||||
const dark = Theme.fromConfig(
|
||||
{ algorithm: antdThemeImport.darkAlgorithm },
|
||||
SUPERSET_BASE_THEME,
|
||||
);
|
||||
|
||||
// Guards the dark algorithm wiring: base surface and text must invert.
|
||||
expect(dark.theme.colorBgBase).not.toBe(light.theme.colorBgBase);
|
||||
expect(dark.theme.colorTextBase).not.toBe(light.theme.colorTextBase);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,730 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`allowed antd token key set is stable 1`] = `
|
||||
[
|
||||
"borderRadius",
|
||||
"borderRadiusLG",
|
||||
"borderRadiusOuter",
|
||||
"borderRadiusSM",
|
||||
"borderRadiusXS",
|
||||
"boxShadow",
|
||||
"boxShadowDrawerLeft",
|
||||
"boxShadowDrawerRight",
|
||||
"boxShadowDrawerUp",
|
||||
"boxShadowPopoverArrow",
|
||||
"boxShadowSecondary",
|
||||
"boxShadowTabsOverflowBottom",
|
||||
"boxShadowTabsOverflowLeft",
|
||||
"boxShadowTabsOverflowRight",
|
||||
"boxShadowTabsOverflowTop",
|
||||
"boxShadowTertiary",
|
||||
"colorBgBase",
|
||||
"colorBgBlur",
|
||||
"colorBgContainer",
|
||||
"colorBgContainerDisabled",
|
||||
"colorBgElevated",
|
||||
"colorBgLayout",
|
||||
"colorBgMask",
|
||||
"colorBgSpotlight",
|
||||
"colorBgTextActive",
|
||||
"colorBgTextHover",
|
||||
"colorBorder",
|
||||
"colorBorderBg",
|
||||
"colorBorderSecondary",
|
||||
"colorError",
|
||||
"colorErrorActive",
|
||||
"colorErrorBg",
|
||||
"colorErrorBgActive",
|
||||
"colorErrorBgHover",
|
||||
"colorErrorBorder",
|
||||
"colorErrorBorderHover",
|
||||
"colorErrorHover",
|
||||
"colorErrorOutline",
|
||||
"colorErrorText",
|
||||
"colorErrorTextActive",
|
||||
"colorErrorTextHover",
|
||||
"colorFill",
|
||||
"colorFillAlter",
|
||||
"colorFillContent",
|
||||
"colorFillContentHover",
|
||||
"colorFillQuaternary",
|
||||
"colorFillSecondary",
|
||||
"colorFillTertiary",
|
||||
"colorHighlight",
|
||||
"colorIcon",
|
||||
"colorIconHover",
|
||||
"colorInfo",
|
||||
"colorInfoActive",
|
||||
"colorInfoBg",
|
||||
"colorInfoBgHover",
|
||||
"colorInfoBorder",
|
||||
"colorInfoBorderHover",
|
||||
"colorInfoHover",
|
||||
"colorInfoText",
|
||||
"colorInfoTextActive",
|
||||
"colorInfoTextHover",
|
||||
"colorLink",
|
||||
"colorLinkActive",
|
||||
"colorLinkHover",
|
||||
"colorPrimary",
|
||||
"colorPrimaryActive",
|
||||
"colorPrimaryBg",
|
||||
"colorPrimaryBgHover",
|
||||
"colorPrimaryBorder",
|
||||
"colorPrimaryBorderHover",
|
||||
"colorPrimaryHover",
|
||||
"colorPrimaryText",
|
||||
"colorPrimaryTextActive",
|
||||
"colorPrimaryTextHover",
|
||||
"colorSplit",
|
||||
"colorSuccess",
|
||||
"colorSuccessActive",
|
||||
"colorSuccessBg",
|
||||
"colorSuccessBgHover",
|
||||
"colorSuccessBorder",
|
||||
"colorSuccessBorderHover",
|
||||
"colorSuccessHover",
|
||||
"colorSuccessText",
|
||||
"colorSuccessTextActive",
|
||||
"colorSuccessTextHover",
|
||||
"colorText",
|
||||
"colorTextBase",
|
||||
"colorTextDescription",
|
||||
"colorTextDisabled",
|
||||
"colorTextHeading",
|
||||
"colorTextLabel",
|
||||
"colorTextLightSolid",
|
||||
"colorTextPlaceholder",
|
||||
"colorTextQuaternary",
|
||||
"colorTextSecondary",
|
||||
"colorTextTertiary",
|
||||
"colorWarning",
|
||||
"colorWarningActive",
|
||||
"colorWarningBg",
|
||||
"colorWarningBgHover",
|
||||
"colorWarningBorder",
|
||||
"colorWarningBorderHover",
|
||||
"colorWarningHover",
|
||||
"colorWarningOutline",
|
||||
"colorWarningText",
|
||||
"colorWarningTextActive",
|
||||
"colorWarningTextHover",
|
||||
"colorWhite",
|
||||
"controlHeight",
|
||||
"controlHeightLG",
|
||||
"controlHeightSM",
|
||||
"controlHeightXS",
|
||||
"controlInteractiveSize",
|
||||
"controlItemBgActive",
|
||||
"controlItemBgActiveDisabled",
|
||||
"controlItemBgActiveHover",
|
||||
"controlItemBgHover",
|
||||
"controlOutline",
|
||||
"controlOutlineWidth",
|
||||
"controlPaddingHorizontal",
|
||||
"controlPaddingHorizontalSM",
|
||||
"controlTmpOutline",
|
||||
"fontFamily",
|
||||
"fontFamilyCode",
|
||||
"fontHeight",
|
||||
"fontHeightLG",
|
||||
"fontHeightSM",
|
||||
"fontSize",
|
||||
"fontSizeHeading1",
|
||||
"fontSizeHeading2",
|
||||
"fontSizeHeading3",
|
||||
"fontSizeHeading4",
|
||||
"fontSizeHeading5",
|
||||
"fontSizeIcon",
|
||||
"fontSizeLG",
|
||||
"fontSizeSM",
|
||||
"fontSizeXL",
|
||||
"fontWeightStrong",
|
||||
"lineHeight",
|
||||
"lineHeightHeading1",
|
||||
"lineHeightHeading2",
|
||||
"lineHeightHeading3",
|
||||
"lineHeightHeading4",
|
||||
"lineHeightHeading5",
|
||||
"lineHeightLG",
|
||||
"lineHeightSM",
|
||||
"lineType",
|
||||
"lineWidth",
|
||||
"lineWidthBold",
|
||||
"lineWidthFocus",
|
||||
"linkDecoration",
|
||||
"linkFocusDecoration",
|
||||
"linkHoverDecoration",
|
||||
"margin",
|
||||
"marginLG",
|
||||
"marginMD",
|
||||
"marginSM",
|
||||
"marginXL",
|
||||
"marginXS",
|
||||
"marginXXL",
|
||||
"marginXXS",
|
||||
"motion",
|
||||
"motionBase",
|
||||
"motionDurationFast",
|
||||
"motionDurationMid",
|
||||
"motionDurationSlow",
|
||||
"motionEaseInBack",
|
||||
"motionEaseInOut",
|
||||
"motionEaseInOutCirc",
|
||||
"motionEaseInQuint",
|
||||
"motionEaseOut",
|
||||
"motionEaseOutBack",
|
||||
"motionEaseOutCirc",
|
||||
"motionEaseOutQuint",
|
||||
"motionUnit",
|
||||
"opacityImage",
|
||||
"opacityLoading",
|
||||
"padding",
|
||||
"paddingContentHorizontal",
|
||||
"paddingContentHorizontalLG",
|
||||
"paddingContentHorizontalSM",
|
||||
"paddingContentVertical",
|
||||
"paddingContentVerticalLG",
|
||||
"paddingContentVerticalSM",
|
||||
"paddingLG",
|
||||
"paddingMD",
|
||||
"paddingSM",
|
||||
"paddingXL",
|
||||
"paddingXS",
|
||||
"paddingXXS",
|
||||
"screenLG",
|
||||
"screenLGMax",
|
||||
"screenLGMin",
|
||||
"screenMD",
|
||||
"screenMDMax",
|
||||
"screenMDMin",
|
||||
"screenSM",
|
||||
"screenSMMax",
|
||||
"screenSMMin",
|
||||
"screenXL",
|
||||
"screenXLMax",
|
||||
"screenXLMin",
|
||||
"screenXS",
|
||||
"screenXSMax",
|
||||
"screenXSMin",
|
||||
"screenXXL",
|
||||
"screenXXLMin",
|
||||
"size",
|
||||
"sizeLG",
|
||||
"sizeMD",
|
||||
"sizeMS",
|
||||
"sizePopupArrow",
|
||||
"sizeSM",
|
||||
"sizeStep",
|
||||
"sizeUnit",
|
||||
"sizeXL",
|
||||
"sizeXS",
|
||||
"sizeXXL",
|
||||
"sizeXXS",
|
||||
"wireframe",
|
||||
"zIndexBase",
|
||||
"zIndexPopupBase",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`computed dark-mode token values match snapshot 1`] = `
|
||||
{
|
||||
"borderRadius": 6,
|
||||
"borderRadiusLG": 8,
|
||||
"borderRadiusOuter": 4,
|
||||
"borderRadiusSM": 4,
|
||||
"borderRadiusXS": 2,
|
||||
"boxShadow": "
|
||||
0 6px 16px 0 rgba(255,255,255,0.016),
|
||||
0 3px 6px -4px rgba(255,255,255,0.024),
|
||||
0 9px 28px 8px rgba(255,255,255,0.010000000000000002)
|
||||
",
|
||||
"boxShadowDrawerLeft": "
|
||||
6px 0 16px 0 rgba(255,255,255,0.016),
|
||||
3px 0 6px -4px rgba(255,255,255,0.024),
|
||||
9px 0 28px 8px rgba(255,255,255,0.010000000000000002)
|
||||
",
|
||||
"boxShadowDrawerRight": "
|
||||
-6px 0 16px 0 rgba(255,255,255,0.016),
|
||||
-3px 0 6px -4px rgba(255,255,255,0.024),
|
||||
-9px 0 28px 8px rgba(255,255,255,0.010000000000000002)
|
||||
",
|
||||
"boxShadowDrawerUp": "
|
||||
0 6px 16px 0 rgba(255,255,255,0.016),
|
||||
0 3px 6px -4px rgba(255,255,255,0.024),
|
||||
0 9px 28px 8px rgba(255,255,255,0.010000000000000002)
|
||||
",
|
||||
"boxShadowPopoverArrow": "2px 2px 5px rgba(255,255,255,0.010000000000000002)",
|
||||
"boxShadowSecondary": "
|
||||
0 6px 16px 0 rgba(255,255,255,0.016),
|
||||
0 3px 6px -4px rgba(255,255,255,0.024),
|
||||
0 9px 28px 8px rgba(255,255,255,0.010000000000000002)
|
||||
",
|
||||
"boxShadowTabsOverflowBottom": "inset 0 -10px 8px -8px rgba(255,255,255,0.016)",
|
||||
"boxShadowTabsOverflowLeft": "inset 10px 0 8px -8px rgba(255,255,255,0.016)",
|
||||
"boxShadowTabsOverflowRight": "inset -10px 0 8px -8px rgba(255,255,255,0.016)",
|
||||
"boxShadowTabsOverflowTop": "inset 0 10px 8px -8px rgba(255,255,255,0.016)",
|
||||
"boxShadowTertiary": "
|
||||
0 1px 2px 0 rgba(255,255,255,0.010000000000000002),
|
||||
0 1px 6px -1px rgba(255,255,255,0.006),
|
||||
0 2px 4px 0 rgba(255,255,255,0.006)
|
||||
",
|
||||
"colorBgBase": "#000",
|
||||
"colorBgBlur": "rgba(255,255,255,0.04)",
|
||||
"colorBgContainer": "#141414",
|
||||
"colorBgContainerDisabled": "rgba(255,255,255,0.08)",
|
||||
"colorBgElevated": "#1f1f1f",
|
||||
"colorBgLayout": "#000000",
|
||||
"colorBgMask": "rgba(0,0,0,0.45)",
|
||||
"colorBgSpotlight": "#424242",
|
||||
"colorBgTextActive": "rgba(255,255,255,0.18)",
|
||||
"colorBgTextHover": "rgba(255,255,255,0.12)",
|
||||
"colorBorder": "#424242",
|
||||
"colorBorderBg": "#141414",
|
||||
"colorBorderSecondary": "#303030",
|
||||
"colorError": "#e04355",
|
||||
"colorErrorActive": "#99333e",
|
||||
"colorErrorBg": "#271619",
|
||||
"colorErrorBgActive": "#512228",
|
||||
"colorErrorBgHover": "#3e1b20",
|
||||
"colorErrorBorder": "#512228",
|
||||
"colorErrorBorderHover": "#702931",
|
||||
"colorErrorHover": "#d7646e",
|
||||
"colorErrorOutline": "rgba(231,42,76,0.09)",
|
||||
"colorErrorText": "#c13c4b",
|
||||
"colorErrorTextActive": "#99333e",
|
||||
"colorErrorTextHover": "#d7646e",
|
||||
"colorFill": "rgba(255,255,255,0.18)",
|
||||
"colorFillAlter": "rgba(255,255,255,0.04)",
|
||||
"colorFillContent": "rgba(255,255,255,0.12)",
|
||||
"colorFillContentHover": "rgba(255,255,255,0.18)",
|
||||
"colorFillQuaternary": "rgba(255,255,255,0.04)",
|
||||
"colorFillSecondary": "rgba(255,255,255,0.12)",
|
||||
"colorFillTertiary": "rgba(255,255,255,0.08)",
|
||||
"colorHighlight": "#c13c4b",
|
||||
"colorIcon": "rgba(255,255,255,0.45)",
|
||||
"colorIconHover": "rgba(255,255,255,0.85)",
|
||||
"colorInfo": "#66bcfe",
|
||||
"colorInfoActive": "#4981ac",
|
||||
"colorInfoBg": "#19222c",
|
||||
"colorInfoBgHover": "#223545",
|
||||
"colorInfoBorder": "#2d465a",
|
||||
"colorInfoBorderHover": "#39607d",
|
||||
"colorInfoHover": "#39607d",
|
||||
"colorInfoText": "#5aa3db",
|
||||
"colorInfoTextActive": "#4981ac",
|
||||
"colorInfoTextHover": "#83bfe8",
|
||||
"colorLink": "#2893B3",
|
||||
"colorLinkActive": "#21677b",
|
||||
"colorLinkHover": "#1d4d5c",
|
||||
"colorPrimary": "#2893B3",
|
||||
"colorPrimaryActive": "#21677b",
|
||||
"colorPrimaryBg": "#1a3a44",
|
||||
"colorPrimaryBgHover": "#1d4d5c",
|
||||
"colorPrimaryBorder": "#1a3a44",
|
||||
"colorPrimaryBorderHover": "#1d4d5c",
|
||||
"colorPrimaryHover": "#4499ae",
|
||||
"colorPrimaryText": "#25809b",
|
||||
"colorPrimaryTextActive": "#21677b",
|
||||
"colorPrimaryTextHover": "#4499ae",
|
||||
"colorSplit": "rgba(253,253,253,0.12)",
|
||||
"colorSuccess": "#5ac189",
|
||||
"colorSuccessActive": "#428460",
|
||||
"colorSuccessBg": "#17231d",
|
||||
"colorSuccessBgHover": "#1f362a",
|
||||
"colorSuccessBorder": "#294837",
|
||||
"colorSuccessBorderHover": "#346249",
|
||||
"colorSuccessHover": "#346249",
|
||||
"colorSuccessText": "#50a777",
|
||||
"colorSuccessTextActive": "#428460",
|
||||
"colorSuccessTextHover": "#77bc94",
|
||||
"colorText": "rgba(255,255,255,0.85)",
|
||||
"colorTextBase": "#fff",
|
||||
"colorTextDescription": "rgba(255,255,255,0.45)",
|
||||
"colorTextDisabled": "rgba(255,255,255,0.25)",
|
||||
"colorTextHeading": "rgba(255,255,255,0.85)",
|
||||
"colorTextLabel": "rgba(255,255,255,0.65)",
|
||||
"colorTextLightSolid": "#fff",
|
||||
"colorTextPlaceholder": "rgba(255,255,255,0.25)",
|
||||
"colorTextQuaternary": "rgba(255,255,255,0.25)",
|
||||
"colorTextSecondary": "rgba(255,255,255,0.65)",
|
||||
"colorTextTertiary": "rgba(255,255,255,0.45)",
|
||||
"colorWarning": "#fcc700",
|
||||
"colorWarningActive": "#ab8807",
|
||||
"colorWarningBg": "#2b2411",
|
||||
"colorWarningBgHover": "#45370f",
|
||||
"colorWarningBorder": "#5a4a0e",
|
||||
"colorWarningBorderHover": "#7c650b",
|
||||
"colorWarningHover": "#7c650b",
|
||||
"colorWarningOutline": "rgba(173,127,0,0.15)",
|
||||
"colorWarningText": "#d9ac03",
|
||||
"colorWarningTextActive": "#ab8807",
|
||||
"colorWarningTextHover": "#e8c427",
|
||||
"colorWhite": "#fff",
|
||||
"controlHeight": 32,
|
||||
"controlHeightLG": 40,
|
||||
"controlHeightSM": 24,
|
||||
"controlHeightXS": 16,
|
||||
"controlInteractiveSize": 16,
|
||||
"controlItemBgActive": "#1a3a44",
|
||||
"controlItemBgActiveDisabled": "rgba(255,255,255,0.18)",
|
||||
"controlItemBgActiveHover": "#1d4d5c",
|
||||
"controlItemBgHover": "rgba(255,255,255,0.08)",
|
||||
"controlOutline": "rgba(49,201,249,0.21)",
|
||||
"controlOutlineWidth": 2,
|
||||
"controlPaddingHorizontal": 12,
|
||||
"controlPaddingHorizontalSM": 8,
|
||||
"controlTmpOutline": "rgba(255,255,255,0.04)",
|
||||
"fontFamily": "'Inter', Helvetica, Arial",
|
||||
"fontFamilyCode": "'IBM Plex Mono', 'Courier New', monospace",
|
||||
"fontHeight": 22,
|
||||
"fontHeightLG": 24,
|
||||
"fontHeightSM": 20,
|
||||
"fontSize": 14,
|
||||
"fontSizeHeading1": 38,
|
||||
"fontSizeHeading2": 30,
|
||||
"fontSizeHeading3": 24,
|
||||
"fontSizeHeading4": 20,
|
||||
"fontSizeHeading5": 16,
|
||||
"fontSizeIcon": 12,
|
||||
"fontSizeLG": 16,
|
||||
"fontSizeSM": 12,
|
||||
"fontSizeXL": 20,
|
||||
"fontWeightStrong": 600,
|
||||
"lineHeight": 1.5714285714285714,
|
||||
"lineHeightHeading1": 1.2105263157894737,
|
||||
"lineHeightHeading2": 1.2666666666666666,
|
||||
"lineHeightHeading3": 1.3333333333333333,
|
||||
"lineHeightHeading4": 1.4,
|
||||
"lineHeightHeading5": 1.5,
|
||||
"lineHeightLG": 1.5,
|
||||
"lineHeightSM": 1.6666666666666667,
|
||||
"lineType": "solid",
|
||||
"lineWidth": 1,
|
||||
"lineWidthBold": 2,
|
||||
"lineWidthFocus": 3,
|
||||
"linkDecoration": "none",
|
||||
"linkFocusDecoration": "none",
|
||||
"linkHoverDecoration": "none",
|
||||
"margin": 16,
|
||||
"marginLG": 24,
|
||||
"marginMD": 20,
|
||||
"marginSM": 12,
|
||||
"marginXL": 32,
|
||||
"marginXS": 8,
|
||||
"marginXXL": 48,
|
||||
"marginXXS": 4,
|
||||
"motion": true,
|
||||
"motionBase": 0,
|
||||
"motionDurationFast": "0.1s",
|
||||
"motionDurationMid": "0.2s",
|
||||
"motionDurationSlow": "0.3s",
|
||||
"motionEaseInBack": "cubic-bezier(0.71, -0.46, 0.88, 0.6)",
|
||||
"motionEaseInOut": "cubic-bezier(0.645, 0.045, 0.355, 1)",
|
||||
"motionEaseInOutCirc": "cubic-bezier(0.78, 0.14, 0.15, 0.86)",
|
||||
"motionEaseInQuint": "cubic-bezier(0.755, 0.05, 0.855, 0.06)",
|
||||
"motionEaseOut": "cubic-bezier(0.215, 0.61, 0.355, 1)",
|
||||
"motionEaseOutBack": "cubic-bezier(0.12, 0.4, 0.29, 1.46)",
|
||||
"motionEaseOutCirc": "cubic-bezier(0.08, 0.82, 0.17, 1)",
|
||||
"motionEaseOutQuint": "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
"motionUnit": 0.1,
|
||||
"opacityImage": 1,
|
||||
"opacityLoading": 0.65,
|
||||
"padding": 16,
|
||||
"paddingContentHorizontal": 16,
|
||||
"paddingContentHorizontalLG": 24,
|
||||
"paddingContentHorizontalSM": 16,
|
||||
"paddingContentVertical": 12,
|
||||
"paddingContentVerticalLG": 16,
|
||||
"paddingContentVerticalSM": 8,
|
||||
"paddingLG": 24,
|
||||
"paddingMD": 20,
|
||||
"paddingSM": 12,
|
||||
"paddingXL": 32,
|
||||
"paddingXS": 8,
|
||||
"paddingXXS": 4,
|
||||
"screenLG": 992,
|
||||
"screenLGMax": 1199,
|
||||
"screenLGMin": 992,
|
||||
"screenMD": 768,
|
||||
"screenMDMax": 991,
|
||||
"screenMDMin": 768,
|
||||
"screenSM": 576,
|
||||
"screenSMMax": 767,
|
||||
"screenSMMin": 576,
|
||||
"screenXL": 1200,
|
||||
"screenXLMax": 1599,
|
||||
"screenXLMin": 1200,
|
||||
"screenXS": 480,
|
||||
"screenXSMax": 575,
|
||||
"screenXSMin": 480,
|
||||
"screenXXL": 1600,
|
||||
"screenXXLMin": 1600,
|
||||
"size": 16,
|
||||
"sizeLG": 24,
|
||||
"sizeMD": 20,
|
||||
"sizeMS": 16,
|
||||
"sizePopupArrow": 16,
|
||||
"sizeSM": 12,
|
||||
"sizeStep": 4,
|
||||
"sizeUnit": 4,
|
||||
"sizeXL": 32,
|
||||
"sizeXS": 8,
|
||||
"sizeXXL": 48,
|
||||
"sizeXXS": 4,
|
||||
"wireframe": false,
|
||||
"zIndexBase": 0,
|
||||
"zIndexPopupBase": 1000,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`computed light-mode token values match snapshot 1`] = `
|
||||
{
|
||||
"borderRadius": 6,
|
||||
"borderRadiusLG": 8,
|
||||
"borderRadiusOuter": 4,
|
||||
"borderRadiusSM": 4,
|
||||
"borderRadiusXS": 2,
|
||||
"boxShadow": "
|
||||
0 6px 16px 0 rgba(0,0,0,0.08),
|
||||
0 3px 6px -4px rgba(0,0,0,0.12),
|
||||
0 9px 28px 8px rgba(0,0,0,0.05)
|
||||
",
|
||||
"boxShadowDrawerLeft": "
|
||||
6px 0 16px 0 rgba(0,0,0,0.08),
|
||||
3px 0 6px -4px rgba(0,0,0,0.12),
|
||||
9px 0 28px 8px rgba(0,0,0,0.05)
|
||||
",
|
||||
"boxShadowDrawerRight": "
|
||||
-6px 0 16px 0 rgba(0,0,0,0.08),
|
||||
-3px 0 6px -4px rgba(0,0,0,0.12),
|
||||
-9px 0 28px 8px rgba(0,0,0,0.05)
|
||||
",
|
||||
"boxShadowDrawerUp": "
|
||||
0 6px 16px 0 rgba(0,0,0,0.08),
|
||||
0 3px 6px -4px rgba(0,0,0,0.12),
|
||||
0 9px 28px 8px rgba(0,0,0,0.05)
|
||||
",
|
||||
"boxShadowPopoverArrow": "2px 2px 5px rgba(0,0,0,0.05)",
|
||||
"boxShadowSecondary": "
|
||||
0 6px 16px 0 rgba(0,0,0,0.08),
|
||||
0 3px 6px -4px rgba(0,0,0,0.12),
|
||||
0 9px 28px 8px rgba(0,0,0,0.05)
|
||||
",
|
||||
"boxShadowTabsOverflowBottom": "inset 0 -10px 8px -8px rgba(0,0,0,0.08)",
|
||||
"boxShadowTabsOverflowLeft": "inset 10px 0 8px -8px rgba(0,0,0,0.08)",
|
||||
"boxShadowTabsOverflowRight": "inset -10px 0 8px -8px rgba(0,0,0,0.08)",
|
||||
"boxShadowTabsOverflowTop": "inset 0 10px 8px -8px rgba(0,0,0,0.08)",
|
||||
"boxShadowTertiary": "
|
||||
0 1px 2px 0 rgba(0,0,0,0.05),
|
||||
0 1px 6px -1px rgba(0,0,0,0.03),
|
||||
0 2px 4px 0 rgba(0,0,0,0.03)
|
||||
",
|
||||
"colorBgBase": "#fff",
|
||||
"colorBgBlur": "transparent",
|
||||
"colorBgContainer": "#ffffff",
|
||||
"colorBgContainerDisabled": "rgba(0,0,0,0.04)",
|
||||
"colorBgElevated": "#ffffff",
|
||||
"colorBgLayout": "#f5f5f5",
|
||||
"colorBgMask": "rgba(0,0,0,0.45)",
|
||||
"colorBgSpotlight": "rgba(0,0,0,0.85)",
|
||||
"colorBgTextActive": "rgba(0,0,0,0.15)",
|
||||
"colorBgTextHover": "rgba(0,0,0,0.06)",
|
||||
"colorBorder": "#d9d9d9",
|
||||
"colorBorderBg": "#ffffff",
|
||||
"colorBorderSecondary": "#f0f0f0",
|
||||
"colorError": "#e04355",
|
||||
"colorErrorActive": "#ba2f43",
|
||||
"colorErrorBg": "#fff0f0",
|
||||
"colorErrorBgActive": "#ffc7c8",
|
||||
"colorErrorBgHover": "#fff0f0",
|
||||
"colorErrorBorder": "#ffc7c8",
|
||||
"colorErrorBorderHover": "#fa9ba0",
|
||||
"colorErrorHover": "#ed6d78",
|
||||
"colorErrorOutline": "rgba(255,5,5,0.06)",
|
||||
"colorErrorText": "#e04355",
|
||||
"colorErrorTextActive": "#ba2f43",
|
||||
"colorErrorTextHover": "#ed6d78",
|
||||
"colorFill": "rgba(0,0,0,0.15)",
|
||||
"colorFillAlter": "rgba(0,0,0,0.02)",
|
||||
"colorFillContent": "rgba(0,0,0,0.06)",
|
||||
"colorFillContentHover": "rgba(0,0,0,0.15)",
|
||||
"colorFillQuaternary": "rgba(0,0,0,0.02)",
|
||||
"colorFillSecondary": "rgba(0,0,0,0.06)",
|
||||
"colorFillTertiary": "rgba(0,0,0,0.04)",
|
||||
"colorHighlight": "#e04355",
|
||||
"colorIcon": "rgba(0,0,0,0.45)",
|
||||
"colorIconHover": "rgba(0,0,0,0.88)",
|
||||
"colorInfo": "#66bcfe",
|
||||
"colorInfoActive": "#4c97d9",
|
||||
"colorInfoBg": "#f0fbff",
|
||||
"colorInfoBgHover": "#f0faff",
|
||||
"colorInfoBorder": "#e0f5ff",
|
||||
"colorInfoBorderHover": "#b8e5ff",
|
||||
"colorInfoHover": "#b8e5ff",
|
||||
"colorInfoText": "#66bcfe",
|
||||
"colorInfoTextActive": "#4c97d9",
|
||||
"colorInfoTextHover": "#8fd2ff",
|
||||
"colorLink": "#2893B3",
|
||||
"colorLinkActive": "#186d8c",
|
||||
"colorLinkHover": "#6ebccc",
|
||||
"colorPrimary": "#2893B3",
|
||||
"colorPrimaryActive": "#186d8c",
|
||||
"colorPrimaryBg": "#e4f1f2",
|
||||
"colorPrimaryBgHover": "#c5e2e6",
|
||||
"colorPrimaryBorder": "#98d0d9",
|
||||
"colorPrimaryBorderHover": "#6ebccc",
|
||||
"colorPrimaryHover": "#49a8bf",
|
||||
"colorPrimaryText": "#2893b3",
|
||||
"colorPrimaryTextActive": "#186d8c",
|
||||
"colorPrimaryTextHover": "#49a8bf",
|
||||
"colorSplit": "rgba(5,5,5,0.06)",
|
||||
"colorSuccess": "#5ac189",
|
||||
"colorSuccessActive": "#419c6d",
|
||||
"colorSuccessBg": "#f0fff4",
|
||||
"colorSuccessBgHover": "#e6f5eb",
|
||||
"colorSuccessBorder": "#dae8df",
|
||||
"colorSuccessBorderHover": "#addbbf",
|
||||
"colorSuccessHover": "#addbbf",
|
||||
"colorSuccessText": "#5ac189",
|
||||
"colorSuccessTextActive": "#419c6d",
|
||||
"colorSuccessTextHover": "#82cfa2",
|
||||
"colorText": "rgba(0,0,0,0.88)",
|
||||
"colorTextBase": "#000",
|
||||
"colorTextDescription": "rgba(0,0,0,0.45)",
|
||||
"colorTextDisabled": "rgba(0,0,0,0.25)",
|
||||
"colorTextHeading": "rgba(0,0,0,0.88)",
|
||||
"colorTextLabel": "rgba(0,0,0,0.65)",
|
||||
"colorTextLightSolid": "#fff",
|
||||
"colorTextPlaceholder": "rgba(0,0,0,0.25)",
|
||||
"colorTextQuaternary": "rgba(0,0,0,0.25)",
|
||||
"colorTextSecondary": "rgba(0,0,0,0.65)",
|
||||
"colorTextTertiary": "rgba(0,0,0,0.45)",
|
||||
"colorWarning": "#fcc700",
|
||||
"colorWarningActive": "#d6a100",
|
||||
"colorWarningBg": "#fffee6",
|
||||
"colorWarningBgHover": "#fff7a3",
|
||||
"colorWarningBorder": "#fff07a",
|
||||
"colorWarningBorderHover": "#ffe552",
|
||||
"colorWarningHover": "#ffe552",
|
||||
"colorWarningOutline": "rgba(255,245,5,0.1)",
|
||||
"colorWarningText": "#fcc700",
|
||||
"colorWarningTextActive": "#d6a100",
|
||||
"colorWarningTextHover": "#ffd829",
|
||||
"colorWhite": "#fff",
|
||||
"controlHeight": 32,
|
||||
"controlHeightLG": 40,
|
||||
"controlHeightSM": 24,
|
||||
"controlHeightXS": 16,
|
||||
"controlInteractiveSize": 16,
|
||||
"controlItemBgActive": "#e4f1f2",
|
||||
"controlItemBgActiveDisabled": "rgba(0,0,0,0.15)",
|
||||
"controlItemBgActiveHover": "#c5e2e6",
|
||||
"controlItemBgHover": "rgba(0,0,0,0.04)",
|
||||
"controlOutline": "rgba(10,128,137,0.11)",
|
||||
"controlOutlineWidth": 2,
|
||||
"controlPaddingHorizontal": 12,
|
||||
"controlPaddingHorizontalSM": 8,
|
||||
"controlTmpOutline": "rgba(0,0,0,0.02)",
|
||||
"fontFamily": "'Inter', Helvetica, Arial",
|
||||
"fontFamilyCode": "'IBM Plex Mono', 'Courier New', monospace",
|
||||
"fontHeight": 22,
|
||||
"fontHeightLG": 24,
|
||||
"fontHeightSM": 20,
|
||||
"fontSize": 14,
|
||||
"fontSizeHeading1": 38,
|
||||
"fontSizeHeading2": 30,
|
||||
"fontSizeHeading3": 24,
|
||||
"fontSizeHeading4": 20,
|
||||
"fontSizeHeading5": 16,
|
||||
"fontSizeIcon": 12,
|
||||
"fontSizeLG": 16,
|
||||
"fontSizeSM": 12,
|
||||
"fontSizeXL": 20,
|
||||
"fontWeightStrong": 600,
|
||||
"lineHeight": 1.5714285714285714,
|
||||
"lineHeightHeading1": 1.2105263157894737,
|
||||
"lineHeightHeading2": 1.2666666666666666,
|
||||
"lineHeightHeading3": 1.3333333333333333,
|
||||
"lineHeightHeading4": 1.4,
|
||||
"lineHeightHeading5": 1.5,
|
||||
"lineHeightLG": 1.5,
|
||||
"lineHeightSM": 1.6666666666666667,
|
||||
"lineType": "solid",
|
||||
"lineWidth": 1,
|
||||
"lineWidthBold": 2,
|
||||
"lineWidthFocus": 3,
|
||||
"linkDecoration": "none",
|
||||
"linkFocusDecoration": "none",
|
||||
"linkHoverDecoration": "none",
|
||||
"margin": 16,
|
||||
"marginLG": 24,
|
||||
"marginMD": 20,
|
||||
"marginSM": 12,
|
||||
"marginXL": 32,
|
||||
"marginXS": 8,
|
||||
"marginXXL": 48,
|
||||
"marginXXS": 4,
|
||||
"motion": true,
|
||||
"motionBase": 0,
|
||||
"motionDurationFast": "0.1s",
|
||||
"motionDurationMid": "0.2s",
|
||||
"motionDurationSlow": "0.3s",
|
||||
"motionEaseInBack": "cubic-bezier(0.71, -0.46, 0.88, 0.6)",
|
||||
"motionEaseInOut": "cubic-bezier(0.645, 0.045, 0.355, 1)",
|
||||
"motionEaseInOutCirc": "cubic-bezier(0.78, 0.14, 0.15, 0.86)",
|
||||
"motionEaseInQuint": "cubic-bezier(0.755, 0.05, 0.855, 0.06)",
|
||||
"motionEaseOut": "cubic-bezier(0.215, 0.61, 0.355, 1)",
|
||||
"motionEaseOutBack": "cubic-bezier(0.12, 0.4, 0.29, 1.46)",
|
||||
"motionEaseOutCirc": "cubic-bezier(0.08, 0.82, 0.17, 1)",
|
||||
"motionEaseOutQuint": "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
"motionUnit": 0.1,
|
||||
"opacityImage": 1,
|
||||
"opacityLoading": 0.65,
|
||||
"padding": 16,
|
||||
"paddingContentHorizontal": 16,
|
||||
"paddingContentHorizontalLG": 24,
|
||||
"paddingContentHorizontalSM": 16,
|
||||
"paddingContentVertical": 12,
|
||||
"paddingContentVerticalLG": 16,
|
||||
"paddingContentVerticalSM": 8,
|
||||
"paddingLG": 24,
|
||||
"paddingMD": 20,
|
||||
"paddingSM": 12,
|
||||
"paddingXL": 32,
|
||||
"paddingXS": 8,
|
||||
"paddingXXS": 4,
|
||||
"screenLG": 992,
|
||||
"screenLGMax": 1199,
|
||||
"screenLGMin": 992,
|
||||
"screenMD": 768,
|
||||
"screenMDMax": 991,
|
||||
"screenMDMin": 768,
|
||||
"screenSM": 576,
|
||||
"screenSMMax": 767,
|
||||
"screenSMMin": 576,
|
||||
"screenXL": 1200,
|
||||
"screenXLMax": 1599,
|
||||
"screenXLMin": 1200,
|
||||
"screenXS": 480,
|
||||
"screenXSMax": 575,
|
||||
"screenXSMin": 480,
|
||||
"screenXXL": 1600,
|
||||
"screenXXLMin": 1600,
|
||||
"size": 16,
|
||||
"sizeLG": 24,
|
||||
"sizeMD": 20,
|
||||
"sizeMS": 16,
|
||||
"sizePopupArrow": 16,
|
||||
"sizeSM": 12,
|
||||
"sizeStep": 4,
|
||||
"sizeUnit": 4,
|
||||
"sizeXL": 32,
|
||||
"sizeXS": 8,
|
||||
"sizeXXL": 48,
|
||||
"sizeXXS": 4,
|
||||
"wireframe": false,
|
||||
"zIndexBase": 0,
|
||||
"zIndexPopupBase": 1000,
|
||||
}
|
||||
`;
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { theme as antdThemeImport } from 'antd';
|
||||
import { theme as antdThemeImport, type MappingAlgorithm } from 'antd';
|
||||
import {
|
||||
type AntdThemeConfig,
|
||||
type AnyThemeConfig,
|
||||
@@ -48,8 +48,8 @@ export function isSerializableConfig(
|
||||
export function deserializeThemeConfig(
|
||||
config: SerializableThemeConfig,
|
||||
): AntdThemeConfig {
|
||||
const { algorithm, ...rest } = config;
|
||||
const algorithmMap: Record<string, any> = {
|
||||
const { algorithm, cssVar, ...rest } = config;
|
||||
const algorithmMap: Record<string, MappingAlgorithm> = {
|
||||
default: antdThemeImport.defaultAlgorithm,
|
||||
dark: antdThemeImport.darkAlgorithm,
|
||||
compact: antdThemeImport.compactAlgorithm,
|
||||
@@ -74,9 +74,17 @@ export function deserializeThemeConfig(
|
||||
resolvedAlgorithm = antdThemeImport.defaultAlgorithm;
|
||||
}
|
||||
|
||||
// Ant Design v6 dropped `boolean` from ThemeConfig['cssVar'] (it is now
|
||||
// object-only, and CSS variables are enabled by default). Superset keeps a
|
||||
// boolean-friendly serializable API, so coerce at the antd boundary:
|
||||
// `true` -> `{}` (defaults), `false`/undefined -> omit (antd default).
|
||||
const normalizedCssVar =
|
||||
typeof cssVar === 'boolean' ? (cssVar ? {} : undefined) : cssVar;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
algorithm: resolvedAlgorithm,
|
||||
...(normalizedCssVar !== undefined ? { cssVar: normalizedCssVar } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@ant-design/icons": "^5.6.1 || ^6.0.0",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.7.0",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
@@ -100,7 +100,7 @@
|
||||
"@types/react-loadable": "*",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/tinycolor2": "*",
|
||||
"antd": "^5.26.0",
|
||||
"antd": "^6.0.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
|
||||
@@ -92,7 +92,7 @@ const MatrixNoDataComponent = () => {
|
||||
* Individual grid cell component - memoized to prevent unnecessary re-renders
|
||||
*/
|
||||
const MatrixifyGridCell = memo(
|
||||
({ cell, rowHeight, datasource, hooks }: MatrixifyGridCellProps) => {
|
||||
({ cell, hooks }: MatrixifyGridCellProps) => {
|
||||
// Use computed title from template (will be empty string if no template)
|
||||
const cellLabel = cell.title || '';
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ interface MatrixifyGridRendererProps {
|
||||
function MatrixifyGridRenderer({
|
||||
formData,
|
||||
datasource,
|
||||
width,
|
||||
height,
|
||||
hooks,
|
||||
}: MatrixifyGridRendererProps) {
|
||||
@@ -249,7 +248,7 @@ function MatrixifyGridRenderer({
|
||||
{/* Row cells for this column group */}
|
||||
{row
|
||||
.slice(colGroup.startIdx, colGroup.endIdx)
|
||||
.map((cell, colIdx) =>
|
||||
.map(cell =>
|
||||
cell ? (
|
||||
<MatrixifyGridCell
|
||||
key={cell.id}
|
||||
|
||||
@@ -444,7 +444,6 @@ export function AsyncAceEditor(
|
||||
/* Adjust tooltip styles */
|
||||
.ace_tooltip {
|
||||
margin-left: ${token.margin}px;
|
||||
padding: ${token.sizeUnit * 2}px;
|
||||
background-color: ${token.colorBgElevated} !important;
|
||||
color: ${token.colorText} !important;
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
|
||||
@@ -82,8 +82,10 @@ test('collapses on click', async () => {
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button')[0]);
|
||||
|
||||
expect(screen.getByText('Content 1').parentNode).toHaveClass(
|
||||
'ant-collapse-content-hidden',
|
||||
// antd v6 moved the hidden state to the panel wrapper element
|
||||
// (ant-collapse-panel-hidden) instead of the content wrapper.
|
||||
expect(screen.getByText('Content 1').parentElement).toHaveClass(
|
||||
'ant-collapse-panel-hidden',
|
||||
);
|
||||
expect(screen.queryByText('Content 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -52,10 +52,10 @@ const StyledCollapse = styled((props: CollapseProps) => (
|
||||
`
|
||||
border-bottom: 1px solid ${theme.colorBorderSecondary};
|
||||
`}
|
||||
.ant-collapse-content {
|
||||
.ant-collapse-panel {
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
|
||||
.ant-collapse-content-box {
|
||||
.ant-collapse-body {
|
||||
.loading.inline {
|
||||
margin: ${({ theme }) => theme.sizeUnit * 12}px auto;
|
||||
display: block;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { render } from '@superset-ui/core/spec';
|
||||
import { render, waitFor } from '@superset-ui/core/spec';
|
||||
import * as ReactCronPicker from 'react-js-cron';
|
||||
import { CronPicker } from '.';
|
||||
|
||||
@@ -37,3 +37,25 @@ test('Should send correct props to ReactCronPicker', () => {
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
// The invalid-cron error styling was silently dead under antd 6 until
|
||||
// react-js-cron was bumped to v6 (its v5 stylesheet targeted antd 5 Select
|
||||
// classes that no longer exist). Pin the error contract: an invalid value
|
||||
// must fire onError and apply the vendor's error class — the hook that both
|
||||
// the vendor stylesheet and Superset's overrides style the error state
|
||||
// through.
|
||||
test('flags an invalid cron value with the error state', async () => {
|
||||
const onError = jest.fn();
|
||||
const { container } = render(
|
||||
<CronPicker value="not a cron" setValue={jest.fn()} onError={onError} />,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: expect.any(String) }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
container.querySelector('.react-js-cron-error'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ export const CronPicker = styled((props: CronProps) => (
|
||||
|
||||
.react-js-cron-select.ant-select {
|
||||
width: 100%;
|
||||
.ant-select-selector {
|
||||
.ant-select-content {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -188,12 +188,12 @@ export const CronPicker = styled((props: CronProps) => (
|
||||
}
|
||||
}
|
||||
|
||||
.react-js-cron-custom-select .ant-select-selection-placeholder {
|
||||
.react-js-cron-custom-select .ant-select-placeholder {
|
||||
flex: auto;
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
}
|
||||
|
||||
.react-js-cron-custom-select .ant-select-selection-overflow-item {
|
||||
.react-js-cron-custom-select .ant-select-content-item {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -338,9 +338,9 @@ export const DropdownContainer = forwardRef(
|
||||
<>
|
||||
<Global
|
||||
styles={css`
|
||||
.ant-popover-inner {
|
||||
// Some OS versions only show the scroll when hovering.
|
||||
// These settings will make the scroll always visible.
|
||||
.ant-popover-container {
|
||||
/* Some OS versions only show the scroll when hovering. */
|
||||
/* These settings will make the scroll always visible. */
|
||||
::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
@@ -361,7 +361,8 @@ export const DropdownContainer = forwardRef(
|
||||
|
||||
<Popover
|
||||
styles={{
|
||||
body: {
|
||||
// antd v6 renamed the inner content slot `body` -> `container`
|
||||
container: {
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
overflow: showOverflow ? 'auto' : 'visible',
|
||||
},
|
||||
|
||||
@@ -60,6 +60,7 @@ export const FaveStar = ({
|
||||
className="fave-unfave-icon"
|
||||
data-test="fave-unfave-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isStarred ? (
|
||||
<Icons.StarFilled
|
||||
|
||||
@@ -28,6 +28,10 @@ const StyledInput = styled(Input)`
|
||||
margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
|
||||
`;
|
||||
|
||||
const StyledTextArea = styled(Input.TextArea)`
|
||||
margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
|
||||
`;
|
||||
|
||||
const StyledInputPassword = styled(Input.Password)`
|
||||
margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
|
||||
`;
|
||||
@@ -62,6 +66,8 @@ export const LabeledErrorBoundInput = ({
|
||||
get_url,
|
||||
description,
|
||||
isValidating = false,
|
||||
renderAsTextArea,
|
||||
textAreaCss,
|
||||
...props
|
||||
}: LabeledErrorBoundInputProps) => {
|
||||
const hasError = !!errorMessage;
|
||||
@@ -98,6 +104,8 @@ export const LabeledErrorBoundInput = ({
|
||||
}
|
||||
role="textbox"
|
||||
/>
|
||||
) : renderAsTextArea ? (
|
||||
<StyledTextArea css={textAreaCss} {...props} {...validationMethods} />
|
||||
) : (
|
||||
<StyledInput {...props} {...validationMethods} />
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { SerializedStyles } from '@emotion/react';
|
||||
|
||||
export type { FormProps, FormInstance, FormItemProps } from 'antd/es/form';
|
||||
export type { SerializedStyles };
|
||||
|
||||
export interface LabeledErrorBoundInputProps {
|
||||
label?: string;
|
||||
@@ -31,5 +34,7 @@ export interface LabeledErrorBoundInputProps {
|
||||
classname?: string;
|
||||
visibilityToggle?: boolean;
|
||||
isValidating?: boolean;
|
||||
renderAsTextArea?: boolean;
|
||||
textAreaCss?: SerializedStyles;
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
@@ -63,11 +63,11 @@ const IconBlock = styled.div`
|
||||
|
||||
span {
|
||||
margin-top: ${({ theme }) =>
|
||||
2 * theme.sizeUnit}px; // Add spacing between icon and name
|
||||
2 * theme.sizeUnit}px; /* Add spacing between icon and name */
|
||||
font-size: ${({ theme }) =>
|
||||
theme.fontSizeSM}; // Optional: adjust font size for elegance
|
||||
theme.fontSizeSM}; /* Optional: adjust font size for elegance */
|
||||
color: ${({ theme }) =>
|
||||
theme.colorText}; // Optional: subtle color for the name
|
||||
theme.colorText}; /* Optional: subtle color for the name */
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import { fireEvent, render } from '@superset-ui/core/spec';
|
||||
|
||||
import { Icons } from '../Icons';
|
||||
import { Label } from '.';
|
||||
import { LabelGallery, options } from './Label.stories';
|
||||
|
||||
@@ -39,6 +40,21 @@ test('renders with monospace prop', () => {
|
||||
expect(getByText('monospace text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Regression: Ant Design v6's Tag clones the element passed via its `icon` prop
|
||||
// and overwrites that element's inline `style`, which silently dropped the
|
||||
// icon's own color. Label wraps the icon in a span so the wrapper (not the
|
||||
// icon) is Tag's clone target and the icon keeps its explicit color.
|
||||
test('preserves a custom icon color (antd v6 Tag icon-style regression)', () => {
|
||||
const { container } = render(
|
||||
<Label icon={<Icons.CheckCircleOutlined iconColor="#aabbcc" />}>
|
||||
labeled
|
||||
</Label>,
|
||||
);
|
||||
expect(container.querySelector('[role="img"]')).toHaveStyle({
|
||||
color: '#aabbcc',
|
||||
});
|
||||
});
|
||||
|
||||
// test stories from the storybook!
|
||||
test('renders all the storybook gallery variants', () => {
|
||||
const { container } = render(<LabelGallery />);
|
||||
|
||||
@@ -34,7 +34,7 @@ export const Label = forwardRef<HTMLSpanElement, LabelProps>((props, ref) => {
|
||||
onClick,
|
||||
children,
|
||||
icon,
|
||||
id,
|
||||
id: _id,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -76,7 +76,27 @@ export const Label = forwardRef<HTMLSpanElement, LabelProps>((props, ref) => {
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
style={style}
|
||||
icon={icon}
|
||||
/*
|
||||
* Ant Design v6's Tag clones the `icon` element and overrides its inline
|
||||
* `style` (see antd/es/tag: cloneElement(icon, { style: mergedStyles.icon })),
|
||||
* which would drop the icon's own color/size. Wrapping the icon in a span
|
||||
* lets Tag override the (empty) wrapper style while the real icon keeps its
|
||||
* own inline styling.
|
||||
*/
|
||||
icon={
|
||||
icon ? (
|
||||
<span
|
||||
css={css`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : (
|
||||
icon
|
||||
)
|
||||
}
|
||||
css={labelStyles}
|
||||
{...rest}
|
||||
>
|
||||
|
||||
@@ -120,8 +120,8 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
// Keep the close button clickable when modal body content uses
|
||||
// position: sticky with elevated z-index (e.g. DatabaseModal header).
|
||||
/* Keep the close button clickable when modal body content uses */
|
||||
/* position: sticky with elevated z-index (e.g. DatabaseModal header). */
|
||||
z-index: ${theme.zIndexPopupBase + 1};
|
||||
}
|
||||
|
||||
@@ -344,6 +344,8 @@ const CustomModal = ({
|
||||
className="draggable-trigger"
|
||||
onMouseOver={() => dragDisabled && setDragDisabled(false)}
|
||||
onMouseOut={() => !dragDisabled && setDragDisabled(true)}
|
||||
onFocus={() => dragDisabled && setDragDisabled(false)}
|
||||
onBlur={() => !dragDisabled && setDragDisabled(true)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { render, screen, userEvent, fireEvent } from '@superset-ui/core/spec';
|
||||
|
||||
import { ModalTrigger } from '.';
|
||||
|
||||
const mockedProps = {
|
||||
@@ -74,3 +75,46 @@ test('should render a modal after click', async () => {
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('stops propagation of navigation keys to parent elements', async () => {
|
||||
const handleParentKeyDown = jest.fn();
|
||||
|
||||
render(
|
||||
<div onKeyDown={handleParentKeyDown}>
|
||||
<ModalTrigger
|
||||
triggerNode={<span>Trigger</span>}
|
||||
modalBody={<input data-test="modal-input" />}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Trigger'));
|
||||
const input = await screen.findByTestId('modal-input');
|
||||
|
||||
const blockedKeys = [
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'Home',
|
||||
'End',
|
||||
];
|
||||
|
||||
for (const key of blockedKeys) {
|
||||
handleParentKeyDown.mockClear();
|
||||
fireEvent.keyDown(input, { key });
|
||||
expect(handleParentKeyDown).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
// `Tab` must be checked before `Escape`: pressing `Escape` legitimately
|
||||
// closes the modal (a real global listener unmounts the dialog), so any
|
||||
// key fired on the stale `input` reference afterwards can no longer
|
||||
// bubble anywhere.
|
||||
const allowedKeys = ['Tab', 'Escape'];
|
||||
|
||||
for (const key of allowedKeys) {
|
||||
handleParentKeyDown.mockClear();
|
||||
fireEvent.keyDown(input, { key });
|
||||
expect(handleParentKeyDown).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,6 +27,15 @@ import {
|
||||
import { Button } from '../Button';
|
||||
import { Modal } from '../Modal';
|
||||
|
||||
const MENU_NAVIGATION_KEYS = new Set([
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'Home',
|
||||
'End',
|
||||
]);
|
||||
|
||||
export interface ModalTriggerProps {
|
||||
dialogClassName?: string;
|
||||
triggerNode: ReactNode;
|
||||
@@ -140,7 +149,16 @@ export const ModalTrigger = forwardRef(
|
||||
draggableConfig={draggableConfig}
|
||||
destroyOnHidden={destroyOnHidden}
|
||||
>
|
||||
{modalBody}
|
||||
<div
|
||||
style={{ display: 'contents' }}
|
||||
onKeyDown={e => {
|
||||
if (MENU_NAVIGATION_KEYS.has(e.key)) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalBody}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ const defaultProps: PageHeaderWithActionsProps = {
|
||||
data-test="additional-actions-menu"
|
||||
/>
|
||||
),
|
||||
menuDropdownProps: { onVisibleChange: jest.fn(), visible: true },
|
||||
menuDropdownProps: { onOpenChange: jest.fn(), open: true },
|
||||
};
|
||||
|
||||
test('Renders', async () => {
|
||||
@@ -52,5 +52,5 @@ test('Renders', async () => {
|
||||
expect(screen.getByText('Save')).toBeVisible();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
||||
expect(defaultProps.menuDropdownProps.onVisibleChange).toHaveBeenCalled();
|
||||
expect(defaultProps.menuDropdownProps.onOpenChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ const RefreshLabel = ({
|
||||
<Icons.SyncOutlined
|
||||
iconSize="l"
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
css={(theme: SupersetTheme) => ({
|
||||
cursor: 'pointer',
|
||||
|
||||
@@ -119,10 +119,22 @@ const findAllSelectOptions = () =>
|
||||
waitFor(() => getElementsByClassName('.ant-select-item-option-content'));
|
||||
|
||||
const findSelectValue = () =>
|
||||
waitFor(() => getElementByClassName('.ant-select-selection-item'));
|
||||
// antd v6: single-mode value is `.ant-select-content-has-value`, multiple-mode
|
||||
// tags remain `.ant-select-selection-item`.
|
||||
waitFor(() =>
|
||||
getElementByClassName(
|
||||
'.ant-select-content-has-value, .ant-select-selection-item',
|
||||
),
|
||||
);
|
||||
|
||||
const findAllSelectValues = () =>
|
||||
waitFor(() => getElementsByClassName('.ant-select-selection-item'));
|
||||
// antd v6: multiple-mode tags keep `.ant-select-selection-item`, single-mode
|
||||
// value is `.ant-select-content-has-value`.
|
||||
waitFor(() =>
|
||||
getElementsByClassName(
|
||||
'.ant-select-selection-item, .ant-select-content-has-value',
|
||||
),
|
||||
);
|
||||
|
||||
const clearAll = () => userEvent.click(screen.getByLabelText('close-circle'));
|
||||
|
||||
@@ -381,7 +393,7 @@ test('searches for custom fields', async () => {
|
||||
|
||||
test('removes duplicated values', async () => {
|
||||
render(<AsyncSelect {...defaultProps} mode="multiple" allowNewOptions />);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'a,b,b,b,c,d,d',
|
||||
@@ -400,7 +412,7 @@ test('removes duplicated values', async () => {
|
||||
|
||||
test('trims whitespace from pasted comma-separated values', async () => {
|
||||
render(<AsyncSelect {...defaultProps} mode="multiple" allowNewOptions />);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'a, b, c , d',
|
||||
@@ -833,7 +845,7 @@ test('Renders only an overflow tag if dropdown is open in oneLine mode', async (
|
||||
);
|
||||
await open();
|
||||
|
||||
const withinSelector = within(getElementByClassName('.ant-select-selector'));
|
||||
const withinSelector = within(getElementByClassName('.ant-select-content'));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
withinSelector.queryByText(OPTIONS[0].label),
|
||||
@@ -906,7 +918,7 @@ test('fires onChange when pasting a selection', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<AsyncSelect {...defaultProps} onChange={onChange} />);
|
||||
await open();
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => OPTIONS[0].label,
|
||||
@@ -1183,7 +1195,7 @@ test('keeps loading indicator while a newer request is in flight after a stale r
|
||||
});
|
||||
|
||||
const isSpinnerVisible = (): boolean =>
|
||||
Boolean(document.querySelector('.ant-select-arrow .ant-spin'));
|
||||
Boolean(document.querySelector('.ant-select-suffix .ant-spin'));
|
||||
|
||||
try {
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
@@ -1331,7 +1343,7 @@ test('appends page>1 results during an active search and discards them when sear
|
||||
// Wait for loading to finish so handlePagination's `!isLoading` gate is
|
||||
// open before we fire scroll.
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('.ant-select-arrow .ant-spin')).toBeNull(),
|
||||
expect(document.querySelector('.ant-select-suffix .ant-spin')).toBeNull(),
|
||||
);
|
||||
|
||||
// Trigger pagination by dispatching a scroll event on the virtual-list
|
||||
@@ -1411,7 +1423,7 @@ test('pasting an existing option does not duplicate it', async () => {
|
||||
}));
|
||||
render(<AsyncSelect {...defaultProps} options={options} />);
|
||||
await open();
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => OPTIONS[0].label,
|
||||
@@ -1439,7 +1451,7 @@ test('pasting an existing option does not duplicate it in multiple mode', async
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'John,Liam,Peter',
|
||||
@@ -1461,7 +1473,7 @@ test('pasting an non-existent option should not add it if allowNewOptions is fal
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'John',
|
||||
@@ -1475,7 +1487,7 @@ test('onChange is called with the value property when pasting an option that was
|
||||
const onChange = jest.fn();
|
||||
render(<AsyncSelect {...defaultProps} onChange={onChange} />);
|
||||
await open();
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const lastOption = OPTIONS[OPTIONS.length - 1];
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
MAX_TAG_COUNT,
|
||||
TOKEN_SEPARATORS,
|
||||
DEFAULT_SORT_COMPARATOR,
|
||||
DROPDOWN_BUILTIN_PLACEMENTS,
|
||||
} from './constants';
|
||||
|
||||
const Error = ({ error }: { error: string }) => (
|
||||
@@ -152,6 +153,7 @@ const AsyncSelect = forwardRef(
|
||||
ref: ForwardedRef<AsyncSelectRef>,
|
||||
) => {
|
||||
const isSingleMode = mode === 'single';
|
||||
const shouldShowSearch = allowNewOptions ? true : Boolean(showSearch);
|
||||
const [selectValue, setSelectValue] = useState(value);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(loading);
|
||||
@@ -693,7 +695,14 @@ const AsyncSelect = forwardRef(
|
||||
setSelectValue(value);
|
||||
}
|
||||
} else {
|
||||
const token = tokenSeparators.find(token => pastedText.includes(token));
|
||||
// antd v6 widened `tokenSeparators` to `string[] | (input => string[])`;
|
||||
// Superset always uses the array form.
|
||||
const separators = Array.isArray(tokenSeparators)
|
||||
? tokenSeparators
|
||||
: [];
|
||||
const token = separators.find((token: string) =>
|
||||
pastedText.includes(token),
|
||||
);
|
||||
const array = token
|
||||
? uniq(
|
||||
pastedText
|
||||
@@ -770,7 +779,7 @@ const AsyncSelect = forwardRef(
|
||||
// surface, but the underlying input accepts it and we rely on that.
|
||||
onPaste={onPaste}
|
||||
onPopupScroll={handlePagination}
|
||||
onSearch={showSearch ? handleOnSearch : undefined}
|
||||
onSearch={shouldShowSearch ? handleOnSearch : undefined}
|
||||
onSelect={
|
||||
handleOnSelect as unknown as (
|
||||
value: unknown,
|
||||
@@ -781,10 +790,15 @@ const AsyncSelect = forwardRef(
|
||||
options={fullSelectOptions}
|
||||
optionRender={option => <Space>{option.label || option.value}</Space>}
|
||||
placeholder={placeholder}
|
||||
showSearch={allowNewOptions ? true : showSearch}
|
||||
showSearch={shouldShowSearch}
|
||||
tokenSeparators={tokenSeparators}
|
||||
builtinPlacements={DROPDOWN_BUILTIN_PLACEMENTS}
|
||||
value={selectValue}
|
||||
suffixIcon={getSuffixIcon(isLoading, showSearch, isDropdownVisible)}
|
||||
suffixIcon={getSuffixIcon(
|
||||
isLoading,
|
||||
shouldShowSearch,
|
||||
isDropdownVisible,
|
||||
)}
|
||||
menuItemSelectedIcon={
|
||||
invertSelection ? (
|
||||
<StyledStopOutlined iconSize="m" aria-label="stop" />
|
||||
|
||||
@@ -108,10 +108,22 @@ const findAllSelectOptions = () =>
|
||||
waitFor(() => getElementsByClassName('.ant-select-item-option-content'));
|
||||
|
||||
const findSelectValue = () =>
|
||||
waitFor(() => getElementByClassName('.ant-select-selection-item'));
|
||||
// antd v6: single-mode value is `.ant-select-content-has-value`, multiple-mode
|
||||
// tags remain `.ant-select-selection-item`.
|
||||
waitFor(() =>
|
||||
getElementByClassName(
|
||||
'.ant-select-content-has-value, .ant-select-selection-item',
|
||||
),
|
||||
);
|
||||
|
||||
const findAllSelectValues = () =>
|
||||
waitFor(() => [...getElementsByClassName('.ant-select-selection-item')]);
|
||||
// antd v6: multiple-mode tags keep `.ant-select-selection-item`, single-mode
|
||||
// value is `.ant-select-content-has-value`.
|
||||
waitFor(() => [
|
||||
...getElementsByClassName(
|
||||
'.ant-select-selection-item, .ant-select-content-has-value',
|
||||
),
|
||||
]);
|
||||
|
||||
const clearAll = () => userEvent.click(screen.getByLabelText('close-circle'));
|
||||
|
||||
@@ -363,7 +375,7 @@ test('searches for custom fields', async () => {
|
||||
|
||||
test('removes duplicated values', async () => {
|
||||
render(<Select {...defaultProps} mode="multiple" allowNewOptions />);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'a,b,b,b,c,d,d',
|
||||
@@ -380,7 +392,7 @@ test('removes duplicated values', async () => {
|
||||
|
||||
test('trims whitespace from pasted comma-separated values', async () => {
|
||||
render(<Select {...defaultProps} mode="multiple" allowNewOptions />);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'a, b, c , d',
|
||||
@@ -777,7 +789,7 @@ test('Renders only an overflow tag if dropdown is open in oneLine mode', async (
|
||||
);
|
||||
await open();
|
||||
|
||||
const withinSelector = within(getElementByClassName('.ant-select-selector'));
|
||||
const withinSelector = within(getElementByClassName('.ant-select-content'));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
withinSelector.queryByText(OPTIONS[0].label),
|
||||
@@ -810,7 +822,7 @@ test('Maintains stable maxTagCount to prevent click target disappearing in oneLi
|
||||
/>,
|
||||
);
|
||||
|
||||
const withinSelector = within(getElementByClassName('.ant-select-selector'));
|
||||
const withinSelector = within(getElementByClassName('.ant-select-content'));
|
||||
expect(withinSelector.getByText(OPTIONS[0].label)).toBeVisible();
|
||||
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
|
||||
|
||||
@@ -847,9 +859,7 @@ test('dropdown width matches input width after tags collapse in oneLine mode', a
|
||||
|
||||
// Wait for RAF to complete and tags to collapse
|
||||
await waitFor(() => {
|
||||
const withinSelector = within(
|
||||
getElementByClassName('.ant-select-selector'),
|
||||
);
|
||||
const withinSelector = within(getElementByClassName('.ant-select-content'));
|
||||
expect(
|
||||
withinSelector.queryByText(OPTIONS[0].label),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -1085,7 +1095,7 @@ test('fires onChange when pasting a selection', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<Select {...defaultProps} onChange={onChange} />);
|
||||
await open();
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => OPTIONS[0].label,
|
||||
@@ -1113,7 +1123,7 @@ test('does not duplicate options when using numeric values', async () => {
|
||||
test('pasting an existing option does not duplicate it', async () => {
|
||||
render(<Select {...defaultProps} options={[OPTIONS[0]]} />);
|
||||
await open();
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => OPTIONS[0].label,
|
||||
@@ -1139,7 +1149,7 @@ test('pasting an existing option does not duplicate it in multiple mode', async
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'John,Liam,Peter',
|
||||
@@ -1153,7 +1163,7 @@ test('pasting an existing option does not duplicate it in multiple mode', async
|
||||
test('pasting an non-existent option should not add it if allowNewOptions is false', async () => {
|
||||
render(<Select {...defaultProps} options={[]} allowNewOptions={false} />);
|
||||
await open();
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'John',
|
||||
@@ -1179,7 +1189,7 @@ test('keeps pasted values outside loaded options when allowNewOptionsOnPaste is
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
// Liam is a loaded option; OutsideValue is not in the loaded page.
|
||||
@@ -1215,7 +1225,7 @@ test('trims whitespace around pasted comma-separated values', async () => {
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
// Note the space after the comma — it must not leak into the value.
|
||||
@@ -1248,7 +1258,7 @@ test('does not create an empty option when pasting blank text', async () => {
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => ' ',
|
||||
@@ -1269,7 +1279,7 @@ test('does not create an empty option when pasting blank text', async () => {
|
||||
|
||||
test('drops pasted values outside loaded options when allowNewOptionsOnPaste is false', async () => {
|
||||
render(<Select {...defaultProps} mode="multiple" allowNewOptions={false} />);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const input = getElementByClassName('.ant-select-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'Liam,OutsideValue',
|
||||
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
} from './styles';
|
||||
import {
|
||||
DEFAULT_SORT_COMPARATOR,
|
||||
DROPDOWN_ALIGN_BOTTOM,
|
||||
DROPDOWN_BUILTIN_PLACEMENTS,
|
||||
EMPTY_OPTIONS,
|
||||
MAX_TAG_COUNT,
|
||||
TOKEN_SEPARATORS,
|
||||
@@ -130,7 +130,9 @@ const Select = forwardRef(
|
||||
ref: Ref<RefSelectProps>,
|
||||
) => {
|
||||
const isSingleMode = mode === 'single';
|
||||
const shouldShowSearch = allowNewOptions ? true : showSearch;
|
||||
// antd v6 widened `showSearch` to `boolean | SearchConfig`; coerce to a
|
||||
// plain boolean for Superset's internal toggles and helpers.
|
||||
const shouldShowSearch = allowNewOptions ? true : Boolean(showSearch);
|
||||
const [selectValue, setSelectValue] = useState(value);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
@@ -719,7 +721,14 @@ const Select = forwardRef(
|
||||
setSelectValue(value);
|
||||
}
|
||||
} else {
|
||||
const token = tokenSeparators.find(token => pastedText.includes(token));
|
||||
// antd v6 widened `tokenSeparators` to `string[] | (input => string[])`;
|
||||
// Superset always uses the array form.
|
||||
const separators = Array.isArray(tokenSeparators)
|
||||
? tokenSeparators
|
||||
: [];
|
||||
const token = separators.find((token: string) =>
|
||||
pastedText.includes(token),
|
||||
);
|
||||
const array = token
|
||||
? uniq(
|
||||
pastedText
|
||||
@@ -868,8 +877,8 @@ const Select = forwardRef(
|
||||
optionRender={option => <Space>{option.label || option.value}</Space>}
|
||||
oneLine={oneLine}
|
||||
popupMatchSelectWidth={oneLine ? dropdownWidth : true}
|
||||
builtinPlacements={DROPDOWN_BUILTIN_PLACEMENTS}
|
||||
css={props.css}
|
||||
dropdownAlign={DROPDOWN_ALIGN_BOTTOM}
|
||||
{...props}
|
||||
showSearch={shouldShowSearch}
|
||||
ref={ref}
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
*/
|
||||
|
||||
import type { LabeledValue as AntdLabeledValue } from 'antd/es/select';
|
||||
import { DEFAULT_SORT_COMPARATOR } from './constants';
|
||||
import {
|
||||
DEFAULT_SORT_COMPARATOR,
|
||||
DROPDOWN_BUILTIN_PLACEMENTS,
|
||||
} from './constants';
|
||||
|
||||
test('DEFAULT_SORT_COMPARATOR sorts by label text when both labels are strings', () => {
|
||||
const a = { value: 'b', label: 'banana' } as AntdLabeledValue;
|
||||
@@ -47,3 +50,46 @@ test('DEFAULT_SORT_COMPARATOR uses rankedSearchCompare when search is provided',
|
||||
// 'bc' is an exact match to search 'bc', so it should sort first (lower index = negative diff)
|
||||
expect(DEFAULT_SORT_COMPARATOR(a, b, 'bc')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Characterization of the dropdown-positioning fix from #36963. antd 6
|
||||
// removed Select's `dropdownAlign`, so the fix is expressed through
|
||||
// `builtinPlacements` — which replaces antd's ENTIRE placement map. This
|
||||
// pins two things a future edit could silently break:
|
||||
// 1. every placement disables horizontal viewport adjustment (the point
|
||||
// of #36963 — the popup must not shift away from its input), and
|
||||
// 2. all four of antd's placements are present with antd's own
|
||||
// points/offsets, so replacing the map does not change unrelated
|
||||
// positioning behavior.
|
||||
test('DROPDOWN_BUILTIN_PLACEMENTS pins #36963 across all four antd placements', () => {
|
||||
const placements = DROPDOWN_BUILTIN_PLACEMENTS as Record<
|
||||
string,
|
||||
{
|
||||
points: string[];
|
||||
offset: number[];
|
||||
overflow: { adjustX: boolean; adjustY: boolean };
|
||||
}
|
||||
>;
|
||||
|
||||
expect(Object.keys(placements).sort()).toEqual([
|
||||
'bottomLeft',
|
||||
'bottomRight',
|
||||
'topLeft',
|
||||
'topRight',
|
||||
]);
|
||||
|
||||
// antd 6's default points/offsets, unchanged
|
||||
expect(placements.bottomLeft.points).toEqual(['tl', 'bl']);
|
||||
expect(placements.bottomLeft.offset).toEqual([0, 4]);
|
||||
expect(placements.bottomRight.points).toEqual(['tr', 'br']);
|
||||
expect(placements.bottomRight.offset).toEqual([0, 4]);
|
||||
expect(placements.topLeft.points).toEqual(['bl', 'tl']);
|
||||
expect(placements.topLeft.offset).toEqual([0, -4]);
|
||||
expect(placements.topRight.points).toEqual(['br', 'tr']);
|
||||
expect(placements.topRight.offset).toEqual([0, -4]);
|
||||
|
||||
// the #36963 delta: no horizontal adjustment, vertical still allowed
|
||||
Object.values(placements).forEach(placement => {
|
||||
expect(placement.overflow.adjustX).toBe(false);
|
||||
expect(placement.overflow.adjustY).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,13 +16,54 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { LabeledValue as AntdLabeledValue } from 'antd/es/select';
|
||||
import {
|
||||
LabeledValue as AntdLabeledValue,
|
||||
SelectProps as AntdSelectProps,
|
||||
} from 'antd/es/select';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { rankedSearchCompare } from '../../utils/rankedSearchCompare';
|
||||
import { RawValue, SelectProps } from './types';
|
||||
import { RawValue } from './types';
|
||||
|
||||
export const MAX_TAG_COUNT = 4;
|
||||
|
||||
const PLACEMENT_SHARED_CONFIG = {
|
||||
overflow: { adjustX: false, adjustY: true, shiftY: true },
|
||||
htmlRegion: 'visible' as const,
|
||||
dynamicInset: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* antd 6's default Select popup placements with horizontal viewport
|
||||
* adjustment disabled, preserving the dropdown-positioning fix from
|
||||
* #36963. antd 6 removed the `dropdownAlign` prop that fix was expressed
|
||||
* through; `builtinPlacements` is its supported replacement, but it
|
||||
* replaces the entire placement map, so all four entries are provided
|
||||
* (mirroring antd's own defaults except for `adjustX`).
|
||||
*/
|
||||
export const DROPDOWN_BUILTIN_PLACEMENTS: AntdSelectProps['builtinPlacements'] =
|
||||
{
|
||||
bottomLeft: {
|
||||
...PLACEMENT_SHARED_CONFIG,
|
||||
points: ['tl', 'bl'],
|
||||
offset: [0, 4],
|
||||
},
|
||||
bottomRight: {
|
||||
...PLACEMENT_SHARED_CONFIG,
|
||||
points: ['tr', 'br'],
|
||||
offset: [0, 4],
|
||||
},
|
||||
topLeft: {
|
||||
...PLACEMENT_SHARED_CONFIG,
|
||||
points: ['bl', 'tl'],
|
||||
offset: [0, -4],
|
||||
},
|
||||
topRight: {
|
||||
...PLACEMENT_SHARED_CONFIG,
|
||||
points: ['br', 'tr'],
|
||||
offset: [0, -4],
|
||||
},
|
||||
};
|
||||
|
||||
export const TOKEN_SEPARATORS = [',', '\r\n', '\n', '\t', ';'];
|
||||
|
||||
export const EMPTY_OPTIONS = [];
|
||||
@@ -33,12 +74,6 @@ export const SELECT_ALL_VALUE: RawValue = t('Select All');
|
||||
|
||||
export const VIRTUAL_THRESHOLD = 20;
|
||||
|
||||
export const DROPDOWN_ALIGN_BOTTOM: SelectProps['dropdownAlign'] = {
|
||||
points: ['tl', 'bl'],
|
||||
offset: [0, 4],
|
||||
overflow: { adjustX: 0, adjustY: 1 },
|
||||
};
|
||||
|
||||
export const SELECT_ALL_OPTION = {
|
||||
value: SELECT_ALL_VALUE,
|
||||
label: String(SELECT_ALL_VALUE),
|
||||
|
||||
@@ -54,11 +54,13 @@ export const StyledSelect = styled(Select, {
|
||||
flex: ${headerPosition === 'left' ? 1 : 0};
|
||||
line-height: ${theme.sizeXL}px;
|
||||
|
||||
&& .ant-select-selection-search {
|
||||
&& .ant-select-input {
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
&& .ant-select-selection-item, .ant-select-selection-placeholder {
|
||||
&& .ant-select-content,
|
||||
&& .ant-select-selection-item,
|
||||
&& .ant-select-placeholder {
|
||||
max-height: ${theme.sizeXL}px;
|
||||
}
|
||||
.ant-select-selection-item::after {
|
||||
@@ -68,16 +70,16 @@ export const StyledSelect = styled(Select, {
|
||||
${
|
||||
oneLine &&
|
||||
`
|
||||
.ant-select-selection-overflow {
|
||||
.ant-select-content {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.ant-select-selection-overflow-item:not(.ant-select-selection-overflow-item-rest):not(.ant-select-selection-overflow-item-suffix) {
|
||||
.ant-select-content-item:not(.ant-select-content-item-rest):not(.ant-select-content-item-suffix) {
|
||||
flex-shrink: 1;
|
||||
min-width: ${theme.sizeUnit * 13}px;
|
||||
}
|
||||
|
||||
.ant-select-selection-overflow-item-suffix {
|
||||
.ant-select-content-item-suffix {
|
||||
flex: unset;
|
||||
min-width: 0px;
|
||||
}
|
||||
|
||||
@@ -71,12 +71,24 @@ export type AntdExposedProps = Pick<
|
||||
| 'virtual'
|
||||
| 'getPopupContainer'
|
||||
| 'menuItemSelectedIcon'
|
||||
| 'dropdownAlign'
|
||||
>;
|
||||
|
||||
export type SelectOptionsType = Exclude<AntdProps['options'], undefined>;
|
||||
|
||||
export interface BaseSelectProps extends AntdExposedProps {
|
||||
/**
|
||||
* Whether the select is searchable. antd 6 also accepts a `SearchConfig`
|
||||
* object here, but Superset's Select manages search behavior itself
|
||||
* (filtering, sorting, "create option" handling), so only the boolean
|
||||
* form is supported — an object would be silently discarded.
|
||||
*/
|
||||
showSearch?: boolean;
|
||||
/**
|
||||
* Separators used to tokenize pasted text into multiple values.
|
||||
* antd 6 also accepts a function form, but Superset's paste handling
|
||||
* only supports the array form.
|
||||
*/
|
||||
tokenSeparators?: string[];
|
||||
/**
|
||||
* Optional CSS class name to apply to the select container
|
||||
*/
|
||||
|
||||
@@ -99,9 +99,12 @@ test('Body should be visible', () => {
|
||||
test('Body content should be blurred loading', () => {
|
||||
render(<TableCollection {...defaultProps} loading />);
|
||||
|
||||
expect(screen.getByTestId('listview-table').parentNode).toHaveClass(
|
||||
'ant-spin-blur',
|
||||
);
|
||||
// antd v6 removed the `ant-spin-blur` class. The body content is now dimmed
|
||||
// via CSS applied to `.ant-spin-container` while its wrapping `.ant-spin`
|
||||
// carries the `.ant-spin-spinning` class.
|
||||
const container = screen.getByTestId('listview-table').parentNode;
|
||||
expect(container).toHaveClass('ant-spin-container');
|
||||
expect(container?.parentNode).toHaveClass('ant-spin-spinning');
|
||||
});
|
||||
|
||||
test('Should the loading-indicator be visible during loading', () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Tabs', () => {
|
||||
expect(getByText('Tab 3')).toBeInTheDocument();
|
||||
|
||||
const activeTabContent = container.querySelector(
|
||||
'.ant-tabs-tabpane-active',
|
||||
'.ant-tabs-content-active',
|
||||
);
|
||||
|
||||
expect(activeTabContent).toBeDefined();
|
||||
@@ -61,7 +61,7 @@ describe('Tabs', () => {
|
||||
const { container } = render(<Tabs items={defaultItems} />);
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
const tabsNav = container.querySelector('.ant-tabs-nav');
|
||||
const tabsContent = container.querySelector('.ant-tabs-content-holder');
|
||||
const tabsContent = container.querySelector('.ant-tabs-body-holder');
|
||||
|
||||
expect(tabsElement).toBeDefined();
|
||||
expect(tabsNav).toBeDefined();
|
||||
@@ -213,7 +213,7 @@ describe('Tabs', () => {
|
||||
expect(getByText('Legacy Tab 2')).toBeInTheDocument();
|
||||
|
||||
const activeTabContent = container.querySelector(
|
||||
'.ant-tabs-tabpane-active [data-testid="legacy-content-1"]',
|
||||
'.ant-tabs-content-active [data-testid="legacy-content-1"]',
|
||||
);
|
||||
|
||||
expect(activeTabContent).toBeDefined();
|
||||
@@ -311,9 +311,9 @@ test('fullHeight prop renders component hierarchy correctly', () => {
|
||||
const { container } = render(<Tabs items={defaultItems} fullHeight />);
|
||||
|
||||
const tabsElement = container.querySelector('.ant-tabs');
|
||||
const contentHolder = container.querySelector('.ant-tabs-content-holder');
|
||||
const content = container.querySelector('.ant-tabs-content');
|
||||
const tabPane = container.querySelector('.ant-tabs-tabpane');
|
||||
const contentHolder = container.querySelector('.ant-tabs-body-holder');
|
||||
const content = container.querySelector('.ant-tabs-body');
|
||||
const tabPane = container.querySelector('.ant-tabs-content');
|
||||
|
||||
expect(tabsElement).toBeInTheDocument();
|
||||
expect(contentHolder).toBeInTheDocument();
|
||||
@@ -343,9 +343,7 @@ test('fullHeight prop maintains structure when content updates', () => {
|
||||
rerender(<Tabs items={newItems} fullHeight />);
|
||||
|
||||
const updatedTabsElement = container.querySelector('.ant-tabs');
|
||||
const updatedContentHolder = container.querySelector(
|
||||
'.ant-tabs-content-holder',
|
||||
);
|
||||
const updatedContentHolder = container.querySelector('.ant-tabs-body-holder');
|
||||
|
||||
expect(updatedTabsElement).toBeInTheDocument();
|
||||
expect(updatedContentHolder).toBeInTheDocument();
|
||||
@@ -359,7 +357,7 @@ test('fullHeight prop works with allowOverflow to handle tall content', () => {
|
||||
|
||||
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
|
||||
const contentHolder = container.querySelector(
|
||||
'.ant-tabs-content-holder',
|
||||
'.ant-tabs-body-holder',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(tabsElement).toBeInTheDocument();
|
||||
|
||||
@@ -48,7 +48,7 @@ const StyledTabs = ({
|
||||
overflow: ${allowOverflow ? 'visible' : 'hidden'};
|
||||
${fullHeight && 'height: 100%;'}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
.ant-tabs-body-holder {
|
||||
overflow: ${allowOverflow ? 'visible' : 'auto'};
|
||||
${fullHeight && 'height: 100%;'}
|
||||
${
|
||||
@@ -57,10 +57,10 @@ const StyledTabs = ({
|
||||
}
|
||||
${contentPadding}
|
||||
}
|
||||
.ant-tabs-content {
|
||||
.ant-tabs-body {
|
||||
${fullHeight && 'height: 100%;'}
|
||||
}
|
||||
.ant-tabs-tabpane {
|
||||
.ant-tabs-content {
|
||||
${fullHeight && 'height: 100%;'}
|
||||
${contentStyle}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ const Tabs = Object.assign(StyledTabs, {
|
||||
|
||||
const StyledEditableTabs = styled(StyledTabs)`
|
||||
${({ theme, contentStyle }) => `
|
||||
.ant-tabs-content-holder {
|
||||
.ant-tabs-body-holder {
|
||||
background: ${theme.colorBgContainer};
|
||||
${contentStyle}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ function findMatchingTimezone(
|
||||
export default function TimezoneSelector({
|
||||
onTimezoneChange,
|
||||
timezone,
|
||||
minWidth = MIN_SELECT_WIDTH,
|
||||
minWidth: _minWidth = MIN_SELECT_WIDTH,
|
||||
placeholder,
|
||||
...rest
|
||||
}: TimezoneSelectorProps) {
|
||||
|
||||
@@ -78,7 +78,7 @@ InteractiveTooltip.argTypes = {
|
||||
control: { type: 'color' },
|
||||
description: 'Custom background color for the tooltip.',
|
||||
},
|
||||
onVisibleChange: { action: 'onVisibleChange' },
|
||||
onOpenChange: { action: 'onOpenChange' },
|
||||
};
|
||||
|
||||
InteractiveTooltip.parameters = {
|
||||
|
||||
@@ -27,7 +27,8 @@ export const Tooltip = forwardRef<TooltipRef, TooltipProps>(
|
||||
<AntdTooltip
|
||||
ref={ref}
|
||||
styles={{
|
||||
body: { overflow: 'hidden', textOverflow: 'ellipsis' },
|
||||
// antd v6 renamed the inner content slot `body` -> `container`
|
||||
container: { overflow: 'hidden', textOverflow: 'ellipsis' },
|
||||
root: overlayStyle ?? {},
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import TooltipParagraph from '.';
|
||||
|
||||
test('starts hidden with default props', () => {
|
||||
@@ -42,30 +42,11 @@ test('not render on hover when not truncated', async () => {
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('render on hover when truncated', async () => {
|
||||
render(
|
||||
<div style={{ width: '200px' }}>
|
||||
<TooltipParagraph>
|
||||
<span data-test="test-text">This is too long and should truncate.</span>
|
||||
</TooltipParagraph>
|
||||
</div>,
|
||||
);
|
||||
|
||||
// Get the div with the ellipsis class to verify it's truncated
|
||||
const ellipsisElement = screen
|
||||
.getByTestId('test-text')
|
||||
.closest('.ant-typography-ellipsis');
|
||||
expect(ellipsisElement).toBeInTheDocument();
|
||||
|
||||
// Hover over the text
|
||||
await userEvent.hover(screen.getByTestId('test-text'));
|
||||
|
||||
// In Ant Design v5, we can check if the aria-describedby attribute is present
|
||||
// which indicates the tooltip functionality is active
|
||||
await waitFor(() => {
|
||||
const element = screen
|
||||
.getByTestId('test-text')
|
||||
.closest('[aria-describedby]');
|
||||
expect(element).toHaveAttribute('aria-describedby');
|
||||
});
|
||||
});
|
||||
// NOTE: there is intentionally no "renders tooltip when truncated" test here.
|
||||
// The tooltip only activates once antd's Typography reports the text as
|
||||
// truncated via its `onEllipsis` callback, which depends on real layout
|
||||
// measurement (offset/scroll widths, ResizeObserver). jsdom performs no layout,
|
||||
// so truncation is never detected and the tooltip never opens. Ant Design v5 set
|
||||
// `aria-describedby` on the trigger regardless, which let this case be asserted;
|
||||
// v6 only wires it once the tooltip has content, so the truncated branch is no
|
||||
// longer observable in jsdom. It is covered by visual / end-to-end testing.
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* antd DOM-class contract.
|
||||
*
|
||||
* Superset styles some antd internals by targeting their generated `.ant-*`
|
||||
* DOM classes (in `GlobalStyles.tsx`, `Select/styles.tsx`, Tooltip overrides,
|
||||
* etc.). When antd renames those classes across a major version, the overrides
|
||||
* silently stop matching — nothing throws, styling just quietly breaks. The
|
||||
* antd 5 -> 6 upgrade did exactly this (e.g. `.ant-select-selection-item` ->
|
||||
* `.ant-select-content`, `.ant-popover-inner` -> `.ant-popover-container`).
|
||||
*
|
||||
* These render antd directly (not the Superset wrappers) on purpose: they pin
|
||||
* the exact class names our CSS depends on, so a future antd bump that renames
|
||||
* one fails here with a clear pointer instead of shipping a visual regression.
|
||||
*/
|
||||
import { render } from '@superset-ui/core/spec';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Collapse, Modal, Popover, Steps, Tabs, Tag, Tooltip } from 'antd';
|
||||
import { Select } from './Select';
|
||||
|
||||
const antClasses = (root: ParentNode): string[] => {
|
||||
const classes = new Set<string>();
|
||||
root
|
||||
.querySelectorAll('*')
|
||||
.forEach(el =>
|
||||
el.classList.forEach(c => c.startsWith('ant-') && classes.add(c)),
|
||||
);
|
||||
return [...classes];
|
||||
};
|
||||
|
||||
test('Select single-value container class (Select/styles.tsx targets it)', () => {
|
||||
const { container } = render(
|
||||
<Select
|
||||
ariaLabel="contract"
|
||||
value="a"
|
||||
options={[{ label: 'Alpha', value: 'a' }]}
|
||||
/>,
|
||||
);
|
||||
expect(antClasses(container)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ant-select-content',
|
||||
'ant-select-content-has-value',
|
||||
// the search <input>; Select/styles.tsx and the E2E helpers target it
|
||||
'ant-select-input',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('Select multiple overflow + placeholder classes (Select/styles.tsx, CronPicker)', () => {
|
||||
const { container: withTags } = render(
|
||||
<Select
|
||||
ariaLabel="contract-multi"
|
||||
mode="multiple"
|
||||
maxTagCount={1}
|
||||
value={['a', 'b']}
|
||||
options={[
|
||||
{ label: 'Alpha', value: 'a' },
|
||||
{ label: 'Beta', value: 'b' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(antClasses(withTags)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ant-select-content-item',
|
||||
'ant-select-content-item-rest',
|
||||
// the search-input overflow item; Select/styles.tsx oneLine targets it
|
||||
'ant-select-content-item-suffix',
|
||||
// multiple-mode tags keep the v5 class name
|
||||
'ant-select-selection-item',
|
||||
]),
|
||||
);
|
||||
|
||||
const { container: empty } = render(
|
||||
<Select ariaLabel="contract-empty" placeholder="pick" options={[]} />,
|
||||
);
|
||||
expect(antClasses(empty)).toContain('ant-select-placeholder');
|
||||
});
|
||||
|
||||
test('Popover container class (GlobalStyles explore-popover override targets it)', () => {
|
||||
render(
|
||||
<Popover open title="t" content="c">
|
||||
<span>anchor</span>
|
||||
</Popover>,
|
||||
);
|
||||
const classes = antClasses(document.body);
|
||||
expect(classes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ant-popover-container',
|
||||
// body element (was .ant-popover-inner-content in v5); Cypress helpers
|
||||
// and DropdownContainer/ag-grid overrides depend on these
|
||||
'ant-popover-content',
|
||||
'ant-popover-title',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tooltip container class (ColumnElement/DateFunctionTooltip overrides target it)', () => {
|
||||
render(
|
||||
<Tooltip open title="tip">
|
||||
<span>anchor</span>
|
||||
</Tooltip>,
|
||||
);
|
||||
expect(antClasses(document.body)).toContain('ant-tooltip-container');
|
||||
});
|
||||
|
||||
test('Tabs content DOM chain (SQL Lab / Explore fullHeight overrides target it)', () => {
|
||||
// antd 6 restructured the Tabs content DOM. The `fullHeight` height chain in
|
||||
// Tabs.tsx and many app overrides walk body-holder -> body -> content(panel);
|
||||
// if any level's class is stale, the chain collapses and heavy content (the
|
||||
// SQL Lab Ace editor) renders at 0 height. antd 5 -> 6 renamed:
|
||||
// .ant-tabs-content-holder -> .ant-tabs-body-holder
|
||||
// .ant-tabs-content (wrapper) -> .ant-tabs-body (new intermediate level)
|
||||
// .ant-tabs-tabpane (panel) -> .ant-tabs-content (now the [role=tabpanel])
|
||||
const { container } = render(
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'One',
|
||||
children: <div className="ace_editor">editor</div>,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const classes = antClasses(container);
|
||||
expect(classes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ant-tabs-body-holder',
|
||||
'ant-tabs-body',
|
||||
'ant-tabs-content',
|
||||
// active-panel modifier; DatasourceEditor's height override targets it
|
||||
'ant-tabs-content-active',
|
||||
]),
|
||||
);
|
||||
// The [role=tabpanel] must be `.ant-tabs-content` (was `.ant-tabs-tabpane`),
|
||||
// and the removed class must not reappear.
|
||||
const panel = container.querySelector('[role="tabpanel"]');
|
||||
expect(panel).toHaveClass('ant-tabs-content');
|
||||
expect(classes).not.toContain('ant-tabs-tabpane');
|
||||
});
|
||||
|
||||
test('Steps classes (QueryStatusBar, ChartCreation, SQL Lab loading detection)', () => {
|
||||
// `.ant-steps` is what executeQuery()/waitForQueryResults() watch to detect the
|
||||
// SQL Lab loading cycle, and QueryStatusBar/ChartCreation style the item parts.
|
||||
// antd 6 renamed the connector line `.ant-steps-item-tail` -> `.ant-steps-item-rail`
|
||||
// (ChartCreation hides it), while `-item-icon`/`-item-title`/`-item-content` survive.
|
||||
const { container } = render(
|
||||
<Steps
|
||||
current={0}
|
||||
items={[{ title: 'A', description: 'd' }, { title: 'B' }]}
|
||||
/>,
|
||||
);
|
||||
const classes = antClasses(container);
|
||||
expect(classes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ant-steps',
|
||||
'ant-steps-item',
|
||||
'ant-steps-item-icon',
|
||||
'ant-steps-item-title',
|
||||
'ant-steps-item-rail',
|
||||
]),
|
||||
);
|
||||
expect(classes).not.toContain('ant-steps-item-tail');
|
||||
});
|
||||
|
||||
test('Select suffix (arrow) class (plugin-chart-table page-size Select targets it)', () => {
|
||||
const { container } = render(
|
||||
<Select ariaLabel="suffix" options={[{ label: 'A', value: 'a' }]} />,
|
||||
);
|
||||
// antd 6 renamed the Select arrow container `.ant-select-arrow` -> `.ant-select-suffix`.
|
||||
expect(antClasses(container)).toContain('ant-select-suffix');
|
||||
});
|
||||
|
||||
test('Collapse panel/body classes (Collapse.tsx, VizTypeGallery, config modals)', () => {
|
||||
// antd 6 renamed the Collapse content family: .ant-collapse-content ->
|
||||
// .ant-collapse-panel and .ant-collapse-content-box -> .ant-collapse-body
|
||||
// (with -active/-hidden moving to the panel). Several styled overrides and
|
||||
// Cypress helpers walk these classes.
|
||||
const { container } = render(
|
||||
<Collapse
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{ key: '1', label: 'One', children: <div>open</div> },
|
||||
{ key: '2', label: 'Two', children: <div>closed</div> },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const classes = antClasses(container);
|
||||
expect(classes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'ant-collapse-item',
|
||||
'ant-collapse-panel',
|
||||
'ant-collapse-panel-active',
|
||||
'ant-collapse-body',
|
||||
]),
|
||||
);
|
||||
expect(classes).not.toContain('ant-collapse-content');
|
||||
expect(classes).not.toContain('ant-collapse-content-box');
|
||||
});
|
||||
|
||||
test('Modal body class (many *.styles.ts modal overrides target it)', () => {
|
||||
render(
|
||||
<Modal open title="t">
|
||||
body
|
||||
</Modal>,
|
||||
);
|
||||
expect(antClasses(document.body)).toContain('ant-modal-body');
|
||||
});
|
||||
|
||||
test('Tag keeps its v5 trailing margin (GlobalStyles parity rule)', () => {
|
||||
// antd 6 removed the Tag's default margin-inline-end: 8px, expecting
|
||||
// parents to space tags via flex/Space gaps. App layouts rely on the v5
|
||||
// default (e.g. the dashboard header's Published tag sat flush against
|
||||
// the metadata bar without it), so GlobalStyles restores it. The spec
|
||||
// renderer mounts SupersetThemeProvider, which renders GlobalStyles, so
|
||||
// the computed style asserts the whole chain.
|
||||
const { getByText } = render(<Tag>spacing</Tag>);
|
||||
const tag = getByText('spacing').closest('.ant-tag') as HTMLElement;
|
||||
expect(getComputedStyle(tag).getPropertyValue('margin-inline-end')).toBe(
|
||||
'8px',
|
||||
);
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import { TIMEOUT } from '../../utils/constants';
|
||||
const SELECT_SELECTORS = {
|
||||
DROPDOWN: '.ant-select-dropdown',
|
||||
OPTION: '.ant-select-item-option',
|
||||
SEARCH_INPUT: '.ant-select-selection-search-input',
|
||||
SEARCH_INPUT: '.ant-select-input',
|
||||
CLEAR: '.ant-select-clear',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
import { Button, Select } from '../core';
|
||||
|
||||
/**
|
||||
* Dashboard native-filter bar component.
|
||||
*/
|
||||
export class DashboardFilterBar {
|
||||
private static readonly SELECTORS = {
|
||||
FILTER_VALUE: '[data-test="form-item-value"]',
|
||||
APPLY_BUTTON:
|
||||
'[data-test="filter-bar__apply-button"], [data-test="filterbar-action-buttons"] button[type="submit"]',
|
||||
CLEAR_BUTTON: '[data-test="filter-bar__clear-button"]',
|
||||
} as const;
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Selects an option in a native filter by its zero-based position.
|
||||
*/
|
||||
async selectOption(optionText: string, index = 0): Promise<void> {
|
||||
const select = new Select(
|
||||
this.page,
|
||||
this.page
|
||||
.locator(DashboardFilterBar.SELECTORS.FILTER_VALUE)
|
||||
.nth(index)
|
||||
.getByRole('combobox'),
|
||||
);
|
||||
await select.open();
|
||||
await select.clickOption(optionText);
|
||||
await select.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies pending native-filter changes.
|
||||
*/
|
||||
async apply(): Promise<void> {
|
||||
await this.getApplyButton().click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies pending native-filter changes when the Apply button is enabled.
|
||||
*/
|
||||
async applyIfEnabled(): Promise<void> {
|
||||
const applyButton = this.getApplyButton();
|
||||
if (!(await applyButton.isEnabled().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await applyButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all native-filter values without applying the pending changes.
|
||||
*/
|
||||
async clearAll(): Promise<void> {
|
||||
await new Button(
|
||||
this.page,
|
||||
DashboardFilterBar.SELECTORS.CLEAR_BUTTON,
|
||||
).click();
|
||||
}
|
||||
|
||||
private getApplyButton(): Button {
|
||||
return new Button(
|
||||
this.page,
|
||||
this.page.locator(DashboardFilterBar.SELECTORS.APPLY_BUTTON).first(),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
superset-frontend/playwright/components/dashboard/index.ts
Normal file
20
superset-frontend/playwright/components/dashboard/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { DashboardFilterBar } from './DashboardFilterBar';
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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 { Locator, Page } from '@playwright/test';
|
||||
import { Modal } from '../core';
|
||||
|
||||
/**
|
||||
* Native filters and Display Controls configuration modal.
|
||||
*/
|
||||
export class NativeFiltersConfigModal extends Modal {
|
||||
private static readonly TEST_IDS = {
|
||||
SAVE_BUTTON: 'native-filter-modal-save-button',
|
||||
} as const;
|
||||
|
||||
private static readonly LABELS = {
|
||||
DIALOG: 'Add or edit display controls',
|
||||
} as const;
|
||||
|
||||
private readonly specificLocator: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.specificLocator = page.getByRole('dialog', {
|
||||
name: NativeFiltersConfigModal.LABELS.DIALOG,
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
|
||||
override get element(): Locator {
|
||||
return this.specificLocator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a Display Control row by name.
|
||||
* @param name - The Display Control name
|
||||
*/
|
||||
private getDisplayControlRow(name: string): Locator {
|
||||
return this.element.getByRole('tab').filter({
|
||||
has: this.page.getByText(name, { exact: true }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a Display Control for removal.
|
||||
* @param name - The Display Control name
|
||||
*/
|
||||
async removeDisplayControl(name: string): Promise<void> {
|
||||
const controlRow = this.getDisplayControlRow(name);
|
||||
await controlRow.hover();
|
||||
await controlRow
|
||||
.getByRole('button', { name: 'Remove customization' })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the marker shown when a Display Control has been removed.
|
||||
*/
|
||||
getRemovedMarker(): Locator {
|
||||
return this.element.getByText('(Removed)', { exact: true }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the native filters and Display Controls configuration.
|
||||
*/
|
||||
async clickSave(): Promise<void> {
|
||||
await this.element
|
||||
.getByTestId(NativeFiltersConfigModal.TEST_IDS.SAVE_BUTTON)
|
||||
.click();
|
||||
}
|
||||
}
|
||||
@@ -24,5 +24,6 @@ export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
|
||||
export { EditDatasetModal } from './EditDatasetModal';
|
||||
export { ImportDatasetModal } from './ImportDatasetModal';
|
||||
export { NativeFiltersConfigModal } from './NativeFiltersConfigModal';
|
||||
export { SaveDatasetModal } from './SaveDatasetModal';
|
||||
export { SaveQueryModal } from './SaveQueryModal';
|
||||
|
||||
@@ -41,7 +41,14 @@ export class ChartCreationPage {
|
||||
* Gets the dataset selector container (includes the displayed selection value)
|
||||
*/
|
||||
getDatasetSelectContainer(): Locator {
|
||||
return this.page.getByLabel('Dataset', { exact: false }).first();
|
||||
// antd v6 moved the `aria-label` onto the combobox <input> itself, so
|
||||
// getByLabel('Dataset') resolves to the empty input (no text content). The
|
||||
// selected value is rendered in the `.ant-select-content-has-value` element
|
||||
// (which also wraps that input), so scope to it to read the displayed value.
|
||||
return this.page
|
||||
.locator('.ant-select-content-has-value')
|
||||
.filter({ has: this.page.getByLabel('Dataset', { exact: false }) })
|
||||
.first();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Page, Download } from '@playwright/test';
|
||||
import { Page, Download, Locator } from '@playwright/test';
|
||||
import { Menu } from '../components/core';
|
||||
import { DashboardFilterBar } from '../components/dashboard';
|
||||
import { NativeFiltersConfigModal } from '../components/modals';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
|
||||
@@ -27,16 +29,19 @@ import { TIMEOUT } from '../utils/constants';
|
||||
*/
|
||||
export class DashboardPage {
|
||||
private readonly page: Page;
|
||||
private readonly filterBar: DashboardFilterBar;
|
||||
|
||||
private static readonly SELECTORS = {
|
||||
DASHBOARD_HEADER: '[data-test="dashboard-header-container"]',
|
||||
DASHBOARD_MENU_TRIGGER: '[data-test="actions-trigger"]',
|
||||
// The header-actions-menu is the data-test for the dropdown menu content
|
||||
HEADER_ACTIONS_MENU: '[data-test="header-actions-menu"]',
|
||||
FILTER_BAR_SETTINGS: '[data-test="filterbar-orientation-icon"]',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.filterBar = new DashboardFilterBar(page);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,6 +99,52 @@ export class DashboardPage {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Display controls section heading in the filter bar.
|
||||
*/
|
||||
getDisplayControlsHeader(): Locator {
|
||||
return this.page.getByRole('heading', {
|
||||
name: 'Display controls',
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a Display Control heading by name.
|
||||
* @param name - The Display Control name
|
||||
*/
|
||||
getDisplayControl(name: string): Locator {
|
||||
return this.page.getByRole('heading', { name, exact: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the dashboard native-filter bar component.
|
||||
*/
|
||||
getFilterBar(): DashboardFilterBar {
|
||||
return this.filterBar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the native filters and Display Controls configuration modal.
|
||||
*/
|
||||
async openNativeFiltersConfigModal(): Promise<NativeFiltersConfigModal> {
|
||||
await this.page.click(DashboardPage.SELECTORS.FILTER_BAR_SETTINGS);
|
||||
await this.page
|
||||
.getByText('Add or edit filters and controls', { exact: true })
|
||||
.click();
|
||||
|
||||
const modal = new NativeFiltersConfigModal(this.page);
|
||||
await modal.waitForVisible();
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies pending native filter changes when the Apply button is enabled.
|
||||
*/
|
||||
async applyFiltersIfEnabled(): Promise<void> {
|
||||
await this.filterBar.applyIfEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the dashboard header actions menu (three-dot menu)
|
||||
*/
|
||||
|
||||
@@ -35,7 +35,7 @@ export class ExplorePage {
|
||||
SOUTH_PANE: '[data-test="some-purposeful-instance"]',
|
||||
EXPAND_DATA_PANEL: '[aria-label="Expand data panel"]',
|
||||
RESULTS_TAB: '[data-node-key="results"]',
|
||||
ACTIVE_TABPANE: '.ant-tabs-tabpane-active',
|
||||
ACTIVE_TABPANE: '.ant-tabs-content-active',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
|
||||
51
superset-frontend/playwright/pages/HomePage.ts
Normal file
51
superset-frontend/playwright/pages/HomePage.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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 { Locator, Page } from '@playwright/test';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
/**
|
||||
* Home/welcome page object.
|
||||
*/
|
||||
export class HomePage {
|
||||
private readonly page: Page;
|
||||
|
||||
private static readonly SECTION_NAMES = {
|
||||
RECENTS: 'Recents',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the Home/welcome page.
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await gotoWithRetry(this.page, URL.WELCOME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Recents content section.
|
||||
*/
|
||||
getRecentsSection(): Locator {
|
||||
return this.page.getByText(HomePage.SECTION_NAMES.RECENTS, { exact: true });
|
||||
}
|
||||
}
|
||||
@@ -17,28 +17,27 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { Request } from '@playwright/test';
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const FILTER_COLUMN = 'gender';
|
||||
|
||||
async function findDatasetIdByName(page: any, name: string): Promise<number> {
|
||||
const rison = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
|
||||
const resp = await page.request.get(`api/v1/dataset/?q=${rison}`);
|
||||
const body = await resp.json();
|
||||
if (!body.result?.length) {
|
||||
throw new Error(`Dataset ${name} not found`);
|
||||
}
|
||||
return body.result[0].id;
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'Clear all filters waits for Apply (sc-105059)',
|
||||
async ({ page, testAssets }) => {
|
||||
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const datasetId = dataset.id;
|
||||
|
||||
// Create a chart that the dashboard filter will target
|
||||
const chartParams = {
|
||||
@@ -148,25 +147,12 @@ testWithAssets(
|
||||
// Visit dashboard
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId);
|
||||
await dashboardPage.waitForLoad();
|
||||
await dashboardPage.waitForLoad({ timeout: TIMEOUT.SLOW_TEST });
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
const filterBar = dashboardPage.getFilterBar();
|
||||
|
||||
// The Gender select should be visible in the filter bar
|
||||
const filterCombobox = page
|
||||
.locator('[data-test="form-item-value"]')
|
||||
.first()
|
||||
.locator('[role="combobox"]');
|
||||
await filterCombobox.click();
|
||||
await page
|
||||
.locator('.ant-select-item-option', { hasText: /^boy$/ })
|
||||
.first()
|
||||
.click();
|
||||
// Close the dropdown
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const applyBtn = page.locator(
|
||||
'[data-test="filter-bar__apply-button"], [data-test="filterbar-action-buttons"] button[type="submit"]',
|
||||
);
|
||||
await filterBar.selectOption('boy');
|
||||
|
||||
// Wait for chart data to come back after Apply
|
||||
const firstApplyResponse = page.waitForResponse(
|
||||
@@ -175,21 +161,20 @@ testWithAssets(
|
||||
r.request().method() === 'POST',
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await applyBtn.first().click();
|
||||
await filterBar.apply();
|
||||
await firstApplyResponse;
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
// Now track POST /api/v1/chart/data requests around Clear All
|
||||
const postsAfterClearAll: string[] = [];
|
||||
const handler = (req: any) => {
|
||||
const handler = (req: Request) => {
|
||||
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
|
||||
postsAfterClearAll.push(req.url());
|
||||
}
|
||||
};
|
||||
page.on('request', handler);
|
||||
|
||||
const clearBtn = page.locator('[data-test="filter-bar__clear-button"]');
|
||||
await clearBtn.click();
|
||||
await filterBar.clearAll();
|
||||
|
||||
// Allow time for any debounced reload to fire if the bug is present
|
||||
await page.waitForTimeout(2000);
|
||||
@@ -211,7 +196,7 @@ testWithAssets(
|
||||
r.request().method() === 'POST',
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await applyBtn.first().click();
|
||||
await filterBar.apply();
|
||||
await applyAfterClearPromise;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression test: a deleted Display Control (chart customization) must not
|
||||
* reappear after clicking "Apply Filters". Seeds a dashboard with a Gender
|
||||
* filter and a Time grain Display Control, deletes the control via the config
|
||||
* modal, then applies and asserts it stays gone.
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
|
||||
// Record video regardless of pass/fail (before/after clips).
|
||||
testWithAssets.use({ video: 'on' });
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const FILTER_COLUMN = 'gender';
|
||||
const TEMPORAL_COLUMN = 'ds';
|
||||
|
||||
testWithAssets(
|
||||
'Deleted Display Control must not reappear after Apply Filters',
|
||||
async ({ page, testAssets }, testInfo) => {
|
||||
testInfo.setTimeout(90000);
|
||||
const shot = (n: string) =>
|
||||
page.screenshot({
|
||||
path: testInfo.outputPath(`${n}.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const datasetId = dataset.id;
|
||||
|
||||
// 1. Seed a chart the filter + control can target.
|
||||
const chartParams = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'big_number_total',
|
||||
metric: 'count',
|
||||
adhoc_filters: [],
|
||||
header_font_size: 0.4,
|
||||
subheader_font_size: 0.15,
|
||||
};
|
||||
const chartResp = await apiPost(page, 'api/v1/chart/', {
|
||||
slice_name: `display_control_repro_${Date.now()}`,
|
||||
viz_type: 'big_number_total',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chart = await chartResp.json();
|
||||
const chartId: number = chart.id ?? chart.result?.id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const chartLayoutKey = `CHART-${chartId}`;
|
||||
const positionJson = {
|
||||
DASHBOARD_VERSION_KEY: 'v2',
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: {
|
||||
type: 'GRID',
|
||||
id: 'GRID_ID',
|
||||
children: ['ROW-1'],
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
'ROW-1': {
|
||||
type: 'ROW',
|
||||
id: 'ROW-1',
|
||||
children: [chartLayoutKey],
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
meta: { background: 'BACKGROUND_TRANSPARENT' },
|
||||
},
|
||||
[chartLayoutKey]: {
|
||||
type: 'CHART',
|
||||
id: chartLayoutKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
|
||||
meta: {
|
||||
chartId,
|
||||
width: 6,
|
||||
height: 50,
|
||||
sliceName: 'display_control_repro',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 2. json_metadata: one dashboard filter + one Display Control.
|
||||
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const customizationId = `CHART_CUSTOMIZATION-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10)}`;
|
||||
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: [
|
||||
{
|
||||
id: filterId,
|
||||
name: 'Gender',
|
||||
filterType: 'filter_select',
|
||||
type: 'NATIVE_FILTER',
|
||||
targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
|
||||
controlValues: {
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
inverseSelection: false,
|
||||
searchAllOptions: false,
|
||||
},
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
chart_customization_config: [
|
||||
{
|
||||
id: customizationId,
|
||||
type: 'CHART_CUSTOMIZATION',
|
||||
name: 'Time grain',
|
||||
filterType: 'chart_customization_timegrain',
|
||||
description: '',
|
||||
targets: [{ datasetId, column: { name: TEMPORAL_COLUMN } }],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
controlValues: {},
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
chartsInScope: [chartId],
|
||||
removed: false,
|
||||
},
|
||||
],
|
||||
chart_configuration: {},
|
||||
cross_filters_enabled: false,
|
||||
global_chart_configuration: {
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
};
|
||||
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `display_control_repro_${Date.now()}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
|
||||
dashboards: [dashboardId],
|
||||
});
|
||||
expect(linkResp.ok()).toBe(true);
|
||||
|
||||
// 3. Open the dashboard.
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId);
|
||||
await dashboardPage.waitForLoad({ timeout: 30000 });
|
||||
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
|
||||
|
||||
// Both the Gender filter and the Time grain Display Control should render.
|
||||
await expect(dashboardPage.getDisplayControlsHeader()).toBeVisible();
|
||||
await expect(dashboardPage.getDisplayControl('Time grain')).toBeVisible();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('STEP 1: Display control "Time grain" is present in the bar.');
|
||||
await shot('01-initial-bar');
|
||||
|
||||
// 4. Open the filters config modal via the settings gear.
|
||||
const modal = await dashboardPage.openNativeFiltersConfigModal();
|
||||
await shot('02-modal-open');
|
||||
|
||||
// 5. Delete the "Time grain" Display Control in the modal sidebar.
|
||||
await modal.removeDisplayControl('Time grain');
|
||||
await expect(modal.getRemovedMarker()).toBeVisible();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('STEP 2: Display control marked (Removed) in modal.');
|
||||
await shot('03-modal-removed');
|
||||
|
||||
// 6. Save the modal.
|
||||
await modal.clickSave();
|
||||
await modal.waitForHidden({ timeout: 20000 });
|
||||
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
|
||||
await shot('04-after-save');
|
||||
|
||||
const goneAfterSave = await dashboardPage
|
||||
.getDisplayControl('Time grain')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`STEP 3: After save, "Time grain" visible in bar = ${goneAfterSave}`,
|
||||
);
|
||||
|
||||
// 7. Click Apply Filters.
|
||||
await dashboardPage.applyFiltersIfEnabled();
|
||||
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await shot('05-after-apply');
|
||||
|
||||
const reappeared = await dashboardPage
|
||||
.getDisplayControl('Time grain')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`STEP 4: After Apply Filters, "Time grain" reappeared = ${reappeared}`,
|
||||
);
|
||||
|
||||
// The deleted Display Control must stay gone.
|
||||
await expect(
|
||||
dashboardPage.getDisplayControl('Time grain'),
|
||||
'Deleted Display Control must not reappear after Apply Filters',
|
||||
).toHaveCount(0);
|
||||
},
|
||||
);
|
||||
@@ -49,7 +49,7 @@ test.describe('Dashboard Export', () => {
|
||||
await dashboardPage.gotoBySlug('world_health');
|
||||
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
// Wait for charts to finish loading - Download menu may be disabled while loading
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
await dashboardPage.waitForChartsToLoad({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
@@ -51,6 +52,8 @@ const COLOR_UNUSED_3: [number, number, number] = [90, 193, 137];
|
||||
testWithAssets(
|
||||
'Gauge renders configured interval colors on a dashboard (#28766)',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user