mirror of
https://github.com/apache/superset.git
synced 2026-07-05 06:15:31 +00:00
Compare commits
77 Commits
fix/export
...
fix/saniti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4df5a15e1 | ||
|
|
687fafd424 | ||
|
|
8fcc0f8b48 | ||
|
|
e08c2c12da | ||
|
|
1e50316bcc | ||
|
|
3e152d9bb7 | ||
|
|
004c401c97 | ||
|
|
a9aabdaedf | ||
|
|
210389478b | ||
|
|
74f815d09c | ||
|
|
43f2816240 | ||
|
|
d250eacaee | ||
|
|
c3fe0a40eb | ||
|
|
71ac6c64e2 | ||
|
|
81a437826f | ||
|
|
fd1f313b30 | ||
|
|
b23cef136e | ||
|
|
681275077b | ||
|
|
d7ad7fbb49 | ||
|
|
59d35d16ce | ||
|
|
4e2160079a | ||
|
|
ceeba01305 | ||
|
|
6d7344750f | ||
|
|
7d90684f93 | ||
|
|
e7b825e26b | ||
|
|
b6f8267ed2 | ||
|
|
d443dd17b9 | ||
|
|
2702113d99 | ||
|
|
0b14f1c226 | ||
|
|
114c258145 | ||
|
|
9f8ff1e87f | ||
|
|
2b30605e3c | ||
|
|
d93098f853 | ||
|
|
eaf6daa7eb | ||
|
|
0e6c5838e4 | ||
|
|
4f37e955b5 | ||
|
|
46a153d17e | ||
|
|
6d22697cba | ||
|
|
7cbdc726e3 | ||
|
|
ceadce234b | ||
|
|
47bc3e2dc1 | ||
|
|
49fcaf2420 | ||
|
|
55088e10da | ||
|
|
bdc610c572 | ||
|
|
a151edeff3 | ||
|
|
d8832c382d | ||
|
|
5bbab86a07 | ||
|
|
ab0e77c1cb | ||
|
|
a30846881b | ||
|
|
22c3f56d0a | ||
|
|
66bf81b997 | ||
|
|
03703843b7 | ||
|
|
f61d6d8b84 | ||
|
|
7eb93c60a3 | ||
|
|
9e08770291 | ||
|
|
358493a2c5 | ||
|
|
83965b4be8 | ||
|
|
f9bcf189c9 | ||
|
|
1c74185a71 | ||
|
|
8bf3933972 | ||
|
|
19e94855a1 | ||
|
|
139df20cde | ||
|
|
4c193d4dbc | ||
|
|
aa40934e7f | ||
|
|
6c2c814b5c | ||
|
|
9769380d6d | ||
|
|
be29d877d2 | ||
|
|
e3b2992d6e | ||
|
|
c1bd45f561 | ||
|
|
7214e9f9f6 | ||
|
|
d7e2f18d00 | ||
|
|
6309d08d59 | ||
|
|
afebdd58d1 | ||
|
|
be46d65e3b | ||
|
|
2992d7b4c8 | ||
|
|
80344852b7 | ||
|
|
8210904e95 |
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -37,8 +37,9 @@ jobs:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Dependencies
|
||||
# Versions are pinned to avoid ad-hoc, unpinned package installs
|
||||
# (zizmor adhoc-packages). Bump deliberately when upgrading.
|
||||
# Versions are pinned to avoid ad-hoc, unpinned package installs.
|
||||
# Bump deliberately when upgrading.
|
||||
# zizmor: ignore[adhoc-packages] - @action-validator is a global CLI tool installed to validate the repo's workflows; a global CLI install has no application manifest/lockfile context, and the versions are pinned above
|
||||
run: npm install -g @action-validator/core@0.6.0 @action-validator/cli@0.6.0
|
||||
|
||||
- name: Run Script
|
||||
|
||||
7
.github/workflows/showtime-trigger.yml
vendored
7
.github/workflows/showtime-trigger.yml
vendored
@@ -2,7 +2,8 @@ name: 🎪 Superset Showtime
|
||||
|
||||
# Ultra-simple: just sync on any PR state change
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers] - required to react to PR label changes; this workflow does not check out or execute PR-provided code
|
||||
# zizmor: ignore[dangerous-triggers] - required to react to PR label changes; PR code is
|
||||
# only checked out and built after the maintainer-authorization gate (write/admin actors)
|
||||
pull_request_target:
|
||||
types: [labeled, unlabeled, synchronize, closed]
|
||||
|
||||
@@ -156,6 +157,10 @@ jobs:
|
||||
with:
|
||||
ref: ${{ steps.check.outputs.target_sha }}
|
||||
persist-credentials: false
|
||||
# Building fork PR code is Showtime's purpose: deploys are gated on the
|
||||
# maintainer-authorization step above (write/admin actors only), so this
|
||||
# checkout is an explicit, authorized opt-in rather than an automatic one.
|
||||
allow-unsafe-pr-checkout: true
|
||||
|
||||
- name: Setup Docker Environment (only if build needed)
|
||||
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true'
|
||||
|
||||
@@ -120,7 +120,7 @@ RUN useradd --user-group -d ${SUPERSET_HOME} -m --no-log-init --shell /bin/bash
|
||||
# Some bash scripts needed throughout the layers
|
||||
COPY --chmod=755 docker/*.sh /app/docker/
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
RUN pip install --no-cache-dir --upgrade uv
|
||||
|
||||
# Using uv as it's faster/simpler than pip
|
||||
RUN uv venv /app/.venv
|
||||
@@ -141,7 +141,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
COPY superset/translations/ /app/translations_mo/
|
||||
RUN if [ "${BUILD_TRANSLATIONS}" = "true" ]; then \
|
||||
pybabel compile -d /app/translations_mo | true; \
|
||||
pybabel compile --use-fuzzy -d /app/translations_mo || true; \
|
||||
fi; \
|
||||
rm -f /app/translations_mo/*/*/*.[po,json]
|
||||
|
||||
|
||||
15
UPDATING.md
15
UPDATING.md
@@ -79,6 +79,19 @@ When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audien
|
||||
|
||||
The git SHA and build number surfaced in the "About" section, the bootstrap payload, and the public `/version` endpoint are now only included for admin users by default; the release version string is still shown to everyone. To expose the build details to all users (the previous behavior), set the `SUPERSET_EXPOSE_BUILD_DETAILS` environment variable (or `EXPOSE_BUILD_DETAILS_TO_USERS = True` in `superset_config.py`).
|
||||
|
||||
### Helm chart adopts Kubernetes recommended labels (breaking upgrade)
|
||||
|
||||
The Helm chart now labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. Because a Deployment's `spec.selector.matchLabels` is immutable, `helm upgrade` against an existing release will fail with a `field is immutable` error.
|
||||
|
||||
To upgrade, delete the affected workloads (which selector labels changed) before upgrading, then run the upgrade so they are recreated with the new labels:
|
||||
|
||||
```bash
|
||||
kubectl delete deployment,statefulset -l release=<release-name> -n <namespace>
|
||||
helm upgrade <release-name> superset/superset
|
||||
```
|
||||
|
||||
Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected.
|
||||
|
||||
### Pivot table First/Last aggregations follow data order
|
||||
|
||||
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.
|
||||
@@ -289,6 +302,8 @@ Schedule the cutover in a quiet window. Runtime reads use only the single config
|
||||
|
||||
The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
|
||||
|
||||
- [39914](https://github.com/apache/superset/pull/39914) `ALERT_REPORT_SLACK_V2` now defaults to `True` and the legacy Slack v1 integration (`Slack` recipient type, `files.upload` API) is deprecated for removal in the next major. Slack blocked new apps from `files.upload` in May 2024 and fully retired the method for all apps on November 12, 2025; because the v1 path sends files through `files.upload`, v1 file-bearing sends now fail at the API level — only text-only `chat_postMessage` still works via the legacy path. Grant your Slack bot the `channels:read` and `groups:read` scopes so existing `Slack` recipients can be auto-upgraded to `SlackV2` on next send. Operators who explicitly override the flag to `False`, or whose Slack bot is missing those scopes, will see deprecation warnings while text-only sends continue through the legacy path.
|
||||
|
||||
### Soft delete and restore for dashboards
|
||||
|
||||
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dashboard/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
|
||||
|
||||
@@ -332,15 +332,28 @@ cd superset-frontend
|
||||
npm run build-translation
|
||||
|
||||
# Backend
|
||||
pybabel compile -d superset/translations
|
||||
pybabel compile --use-fuzzy -d superset/translations
|
||||
```
|
||||
|
||||
`--use-fuzzy` includes `#, fuzzy` entries in the compiled `.mo` files. Superset
|
||||
serves fuzzy translations on purpose: the frontend build (`po2json --fuzzy`)
|
||||
already includes them, `flask fab babel-compile` (used by the release images)
|
||||
compiles with `-f`, and the production `Dockerfile` compiles with `--use-fuzzy`
|
||||
as well. This keeps machine-generated (and other draft) translations visible in
|
||||
the UI rather than falling back to English while they await review.
|
||||
|
||||
### Backfilling missing translations with AI
|
||||
|
||||
For languages with many untranslated strings, the repo includes a script that
|
||||
uses Claude AI to generate draft translations for any missing entries. All
|
||||
AI-generated strings are marked `#, fuzzy` and tagged with an attribution
|
||||
comment so that human reviewers know they need to be checked before merging.
|
||||
comment so that human reviewers know they need to be checked.
|
||||
|
||||
Note that `#, fuzzy` marks a translation as *needing review*, not as *withheld*:
|
||||
both the frontend and backend builds serve fuzzy entries (see [Applying
|
||||
translations](#applying-translations) above), so an AI-generated string is shown
|
||||
in the UI as soon as it is built and deployed. Reviewers should verify each
|
||||
entry and remove the `#, fuzzy` flag to promote it to a confirmed translation.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
|
||||
@@ -749,7 +749,7 @@ const config: Config = {
|
||||
showReadingTime: true,
|
||||
// Please change this to your repo.
|
||||
editUrl:
|
||||
'https://github.com/facebook/docusaurus/edit/main/website/blog/',
|
||||
'https://github.com/apache/superset/tree/master/docs',
|
||||
},
|
||||
theme: {
|
||||
customCss: require.resolve('./src/styles/custom.css'),
|
||||
|
||||
@@ -58,24 +58,14 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^8.6.18",
|
||||
"@storybook/blocks": "^8.6.15",
|
||||
"@storybook/channels": "^8.6.18",
|
||||
"@storybook/client-logger": "^8.6.18",
|
||||
"@storybook/components": "^8.6.18",
|
||||
"@storybook/core": "^8.6.18",
|
||||
"@storybook/core-events": "^8.6.18",
|
||||
"@storybook/csf": "^0.1.13",
|
||||
"@storybook/docs-tools": "^8.6.18",
|
||||
"@storybook/preview-api": "^8.6.18",
|
||||
"@storybook/theming": "^8.6.15",
|
||||
"@storybook/addon-docs": "^10.4.5",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.43",
|
||||
"antd": "^6.4.5",
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"baseline-browser-mapping": "^2.10.40",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.0",
|
||||
"js-yaml": "^5.1.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
@@ -88,7 +78,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^8.6.18",
|
||||
"storybook": "^10.4.5",
|
||||
"swagger-ui-react": "^5.32.8",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
@@ -110,7 +100,7 @@
|
||||
"prettier": "^3.8.4",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"webpack": "^5.107.2"
|
||||
"webpack": "^5.108.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -168,60 +168,6 @@ export default function webpackExtendPlugin(): Plugin<void> {
|
||||
__dirname,
|
||||
'../../superset-frontend/packages/superset-core/src',
|
||||
),
|
||||
// Add proper Storybook aliases
|
||||
'@storybook/blocks': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/blocks',
|
||||
),
|
||||
'@storybook/components': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/components',
|
||||
),
|
||||
'@storybook/theming': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/theming',
|
||||
),
|
||||
'@storybook/client-logger': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/client-logger',
|
||||
),
|
||||
'@storybook/core-events': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/core-events',
|
||||
),
|
||||
// Add internal Storybook aliases
|
||||
'storybook/internal/components': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/components',
|
||||
),
|
||||
'storybook/internal/theming': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/theming',
|
||||
),
|
||||
'storybook/internal/client-logger': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/client-logger',
|
||||
),
|
||||
'storybook/internal/csf': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/csf',
|
||||
),
|
||||
'storybook/internal/preview-api': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/preview-api',
|
||||
),
|
||||
'storybook/internal/docs-tools': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/docs-tools',
|
||||
),
|
||||
'storybook/internal/core-events': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/core-events',
|
||||
),
|
||||
'storybook/internal/channels': path.resolve(
|
||||
__dirname,
|
||||
'../node_modules/@storybook/channels',
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
4
docs/static/feature-flags.json
vendored
4
docs/static/feature-flags.json
vendored
@@ -122,9 +122,9 @@
|
||||
},
|
||||
{
|
||||
"name": "ALERT_REPORT_SLACK_V2",
|
||||
"default": false,
|
||||
"default": true,
|
||||
"lifecycle": "testing",
|
||||
"description": "Enables Slack V2 integration for Alerts and Reports"
|
||||
"description": "Enables Slack V2 integration for Alerts and Reports. Defaults to True; the legacy Slack v1 path is deprecated and will be removed in the next major release. Operators must grant the Slack bot both the `channels:read` and `groups:read` scopes so existing v1 recipients can be auto-upgraded on their next send. Without those scopes, file uploads fail (Slack retired the `files.upload` endpoint in 2025) and only text-only `chat_postMessage` sends will continue to work via the legacy path."
|
||||
},
|
||||
{
|
||||
"name": "ALERT_REPORT_WEBHOOK",
|
||||
|
||||
@@ -519,6 +519,80 @@ For a connection to a SQL endpoint you need to use the HTTP path from the endpoi
|
||||
{"connect_args": {"http_path": "/sql/1.0/endpoints/****", "driver_path": "/path/to/odbc/driver"}}
|
||||
```
|
||||
|
||||
##### OAuth2 Authentication
|
||||
|
||||
Superset supports OAuth2 authentication for Databricks, allowing users to authenticate with their personal Databricks accounts instead of using shared access tokens. This provides better security and audit capabilities.
|
||||
|
||||
###### Prerequisites
|
||||
|
||||
1. Create an OAuth2 application in your Databricks account:
|
||||
- Go to your Databricks account console
|
||||
- Navigate to **Settings** → **Developer** → **OAuth apps**
|
||||
- Create a new OAuth app with the redirect URI: `http://your-superset-host:port/api/v1/database/oauth2/`
|
||||
|
||||
2. Configure OAuth2 in your `superset_config.py`:
|
||||
|
||||
```python
|
||||
from datetime import timedelta
|
||||
|
||||
# OAuth2 configuration for Databricks
|
||||
# The authorization endpoint is derived from your Databricks workspace host; the
|
||||
# token endpoint must be set explicitly (see notes below).
|
||||
DATABASE_OAUTH2_CLIENTS = {
|
||||
"Databricks (legacy)": {
|
||||
"id": "your-databricks-client-id",
|
||||
"secret": "your-databricks-client-secret",
|
||||
"scope": "sql",
|
||||
"token_request_uri": "https://your-workspace-host/oidc/v1/token",
|
||||
},
|
||||
"Databricks": {
|
||||
"id": "your-databricks-client-id",
|
||||
"secret": "your-databricks-client-secret",
|
||||
"scope": "sql",
|
||||
"token_request_uri": "https://your-workspace-host/oidc/v1/token",
|
||||
},
|
||||
}
|
||||
|
||||
# OAuth2 redirect URI (adjust hostname/port for your setup)
|
||||
DATABASE_OAUTH2_REDIRECT_URI = "http://your-superset-host:port/api/v1/database/oauth2/"
|
||||
|
||||
# Optional: OAuth2 timeout
|
||||
DATABASE_OAUTH2_TIMEOUT = timedelta(seconds=30)
|
||||
```
|
||||
|
||||
Replace the following placeholders:
|
||||
- `your-databricks-client-id`: Your Databricks OAuth2 application client ID
|
||||
- `your-databricks-client-secret`: Your Databricks OAuth2 application client secret
|
||||
- `your-superset-host:port`: Your Superset instance hostname and port
|
||||
|
||||
**Multi-Cloud Provider Support**
|
||||
|
||||
Databricks fronts the user-to-machine (U2M) OAuth2 flow on every workspace at
|
||||
`https://<workspace-host>/oidc/v1/authorize` and
|
||||
`https://<workspace-host>/oidc/v1/token`, regardless of whether the workspace
|
||||
runs on AWS, Azure, or GCP. Superset derives the **authorization** endpoint
|
||||
directly from your connection's host, so no cloud provider or account/tenant
|
||||
identifier needs to be configured.
|
||||
|
||||
The **token** endpoint cannot be auto-derived (token exchange has no database
|
||||
context to read the host), so you must supply `token_request_uri` in
|
||||
`DATABASE_OAUTH2_CLIENTS`, set to `https://<workspace-host>/oidc/v1/token` for
|
||||
your workspace.
|
||||
|
||||
If you supply a fully-resolved `authorization_request_uri` (and/or
|
||||
`token_request_uri`), those values take precedence over the host-derived
|
||||
defaults.
|
||||
|
||||
###### Usage
|
||||
|
||||
Once configured, users can:
|
||||
|
||||
1. Connect to Databricks databases normally using access tokens
|
||||
2. When querying data, Superset will automatically redirect users to authenticate with Databricks if needed
|
||||
3. User-specific OAuth2 tokens will be used for database connections, providing better security and audit trails
|
||||
|
||||
This feature works with both "Databricks (legacy)" and "Databricks" engine types and automatically supports all major cloud providers (AWS, Azure, GCP).
|
||||
|
||||
#### Denodo
|
||||
|
||||
The recommended connector library for Denodo is
|
||||
|
||||
1648
docs/yarn.lock
1648
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.18.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.19.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
|
||||
|
||||
@@ -46,6 +46,21 @@ It should be a long random bytes or str.
|
||||
|
||||
On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverrides.secrets`
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### Kubernetes recommended labels (breaking)
|
||||
|
||||
This chart labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. A Deployment's `spec.selector.matchLabels` is immutable, so `helm upgrade` against a release created before this change fails with a `field is immutable` error.
|
||||
|
||||
To upgrade an existing release, delete the affected workloads first (their selector labels changed), then upgrade so they are recreated:
|
||||
|
||||
```console
|
||||
kubectl delete deployment,statefulset -l release=<release-name> -n <namespace>
|
||||
helm upgrade <release-name> superset/superset
|
||||
```
|
||||
|
||||
Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected.
|
||||
|
||||
## Requirements
|
||||
|
||||
| Repository | Name | Version |
|
||||
|
||||
@@ -45,6 +45,21 @@ It should be a long random bytes or str.
|
||||
|
||||
On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverrides.secrets`
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### Kubernetes recommended labels (breaking)
|
||||
|
||||
This chart labels and selects workloads using the [Kubernetes recommended labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/) (`app.kubernetes.io/*`) instead of the legacy `app`/`release` labels. A Deployment's `spec.selector.matchLabels` is immutable, so `helm upgrade` against a release created before this change fails with a `field is immutable` error.
|
||||
|
||||
To upgrade an existing release, delete the affected workloads first (their selector labels changed), then upgrade so they are recreated:
|
||||
|
||||
```console
|
||||
kubectl delete deployment,statefulset -l release=<release-name> -n <namespace>
|
||||
helm upgrade <release-name> superset/superset
|
||||
```
|
||||
|
||||
Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected.
|
||||
|
||||
{{ template "chart.requirementsSection" . }}
|
||||
|
||||
{{ template "chart.valuesSection" . }}
|
||||
|
||||
@@ -61,6 +61,49 @@ Create chart name and version as used by the chart label.
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Common labels for all resources - follows Kubernetes recommended labels
|
||||
https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
|
||||
*/}}
|
||||
{{- define "superset.labels" -}}
|
||||
helm.sh/chart: {{ include "superset.chart" . }}
|
||||
{{ include "superset.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: superset
|
||||
{{- if .Values.extraLabels }}
|
||||
{{ toYaml .Values.extraLabels }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Selector labels - used by selectors and matchLabels
|
||||
*/}}
|
||||
{{- define "superset.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "superset.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Component labels - extends superset.labels with component-specific labels
|
||||
Usage: {{ include "superset.componentLabels" (dict "component" "web" "root" .) }}
|
||||
*/}}
|
||||
{{- define "superset.componentLabels" -}}
|
||||
{{ include "superset.labels" .root }}
|
||||
app.kubernetes.io/component: {{ .component }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Component selector labels - for matchLabels with component
|
||||
Usage: {{ include "superset.componentSelectorLabels" (dict "component" "web" "root" .) }}
|
||||
*/}}
|
||||
{{- define "superset.componentSelectorLabels" -}}
|
||||
{{ include "superset.selectorLabels" .root }}
|
||||
app.kubernetes.io/component: {{ .component }}
|
||||
{{- end -}}
|
||||
|
||||
|
||||
{{- define "superset-config" }}
|
||||
import os
|
||||
@@ -146,27 +189,32 @@ RESULTS_BACKEND = RedisCache(
|
||||
|
||||
{{- end }}
|
||||
|
||||
{{- define "supersetNode.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "superset.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: web
|
||||
{{- end }}
|
||||
|
||||
{{- define "supersetCeleryBeat.selectorLabels" -}}
|
||||
app: {{ include "superset.name" . }}-celerybeat
|
||||
release: {{ .Release.Name }}
|
||||
app.kubernetes.io/name: {{ include "superset.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: celerybeat
|
||||
{{- end }}
|
||||
|
||||
{{- define "supersetCeleryFlower.selectorLabels" -}}
|
||||
app: {{ include "superset.name" . }}-flower
|
||||
release: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "supersetNode.selectorLabels" -}}
|
||||
app: {{ include "superset.name" . }}
|
||||
release: {{ .Release.Name }}
|
||||
app.kubernetes.io/name: {{ include "superset.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: flower
|
||||
{{- end }}
|
||||
|
||||
{{- define "supersetWebsockets.selectorLabels" -}}
|
||||
app: {{ include "superset.name" . }}-ws
|
||||
release: {{ .Release.Name }}
|
||||
app.kubernetes.io/name: {{ include "superset.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: websocket
|
||||
{{- end }}
|
||||
|
||||
{{- define "supersetWorker.selectorLabels" -}}
|
||||
app: {{ include "superset.name" . }}-worker
|
||||
release: {{ .Release.Name }}
|
||||
app.kubernetes.io/name: {{ include "superset.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: worker
|
||||
{{- end }}
|
||||
|
||||
@@ -24,13 +24,7 @@ metadata:
|
||||
name: {{ template "superset.fullname" . }}-extra-config
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.labels" . | nindent 4 }}
|
||||
data:
|
||||
{{- range $path, $config := .Values.extraConfigs }}
|
||||
{{ $path }}: |
|
||||
|
||||
@@ -24,13 +24,7 @@ metadata:
|
||||
name: {{ template "superset.fullname" . }}-celerybeat
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}-celerybeat
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "celerybeat" "root" .) | nindent 4 }}
|
||||
{{- if .Values.supersetCeleryBeat.deploymentAnnotations }}
|
||||
annotations: {{- toYaml .Values.supersetCeleryBeat.deploymentAnnotations | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -59,11 +53,7 @@ spec:
|
||||
{{- toYaml .Values.supersetCeleryBeat.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
app: "{{ template "superset.name" . }}-celerybeat"
|
||||
release: {{ .Release.Name }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- include "supersetCeleryBeat.selectorLabels" . | nindent 8 }}
|
||||
{{- if .Values.supersetCeleryBeat.podLabels }}
|
||||
{{- toYaml .Values.supersetCeleryBeat.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -24,13 +24,7 @@ metadata:
|
||||
name: {{ template "superset.fullname" . }}-flower
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}-flower
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "flower" "root" .) | nindent 4 }}
|
||||
{{- if .Values.supersetCeleryFlower.deploymentAnnotations }}
|
||||
annotations: {{- toYaml .Values.supersetCeleryFlower.deploymentAnnotations | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -48,11 +42,7 @@ spec:
|
||||
{{- toYaml .Values.supersetCeleryFlower.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
app: "{{ template "superset.name" . }}-flower"
|
||||
release: {{ .Release.Name }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- include "supersetCeleryFlower.selectorLabels" . | nindent 8 }}
|
||||
{{- if .Values.supersetCeleryFlower.podLabels }}
|
||||
{{- toYaml .Values.supersetCeleryFlower.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -23,15 +23,9 @@ metadata:
|
||||
name: {{ template "superset.fullname" . }}-worker
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}-worker
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.supersetWorker.deploymentLabels }}
|
||||
{{- toYaml .Values.supersetWorker.deploymentLabels | nindent 4 }}
|
||||
{{- include "superset.componentLabels" (dict "component" "worker" "root" .) | nindent 4 }}
|
||||
{{- with .Values.supersetWorker.deploymentLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.supersetWorker.deploymentAnnotations }}
|
||||
annotations: {{- toYaml .Values.supersetWorker.deploymentAnnotations | nindent 4 }}
|
||||
@@ -65,11 +59,7 @@ spec:
|
||||
{{- toYaml .Values.supersetWorker.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}-worker
|
||||
release: {{ .Release.Name }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- include "supersetWorker.selectorLabels" . | nindent 8 }}
|
||||
{{- if .Values.supersetWorker.podLabels }}
|
||||
{{- toYaml .Values.supersetWorker.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -24,13 +24,7 @@ metadata:
|
||||
name: "{{ template "superset.fullname" . }}-ws"
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: "{{ template "superset.name" . }}-ws"
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "websocket" "root" .) | nindent 4 }}
|
||||
{{- if .Values.supersetWebsockets.deploymentAnnotations }}
|
||||
annotations: {{- toYaml .Values.supersetWebsockets.deploymentAnnotations | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -51,11 +45,7 @@ spec:
|
||||
{{- toYaml .Values.supersetWebsockets.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
app: "{{ template "superset.name" . }}-ws"
|
||||
release: {{ .Release.Name }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- include "supersetWebsockets.selectorLabels" . | nindent 8 }}
|
||||
{{- if .Values.supersetWebsockets.podLabels }}
|
||||
{{- toYaml .Values.supersetWebsockets.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -23,15 +23,9 @@ metadata:
|
||||
name: {{ template "superset.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.supersetNode.deploymentLabels }}
|
||||
{{- toYaml .Values.supersetNode.deploymentLabels | nindent 4 }}
|
||||
{{- include "superset.componentLabels" (dict "component" "web" "root" .) | nindent 4 }}
|
||||
{{- with .Values.supersetNode.deploymentLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.supersetNode.deploymentAnnotations }}
|
||||
annotations: {{- toYaml .Values.supersetNode.deploymentAnnotations | nindent 4 }}
|
||||
@@ -67,11 +61,7 @@ spec:
|
||||
{{- toYaml .Values.supersetNode.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
release: {{ .Release.Name }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- include "supersetNode.selectorLabels" . | nindent 8 }}
|
||||
{{- if .Values.supersetNode.podLabels }}
|
||||
{{- toYaml .Values.supersetNode.podLabels | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -23,13 +23,7 @@ kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "superset.fullname" . }}-hpa
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
|
||||
@@ -23,13 +23,7 @@ kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "superset.fullname" . }}-hpa-worker
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
|
||||
@@ -25,13 +25,7 @@ metadata:
|
||||
name: {{ $fullName }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "ingress" "root" .) | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations: {{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -24,13 +24,7 @@ metadata:
|
||||
name: {{ template "superset.fullname" . }}-init-db
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "init" "root" .) | nindent 4 }}
|
||||
{{- if .Values.init.jobAnnotations }}
|
||||
annotations: {{- toYaml .Values.init.jobAnnotations | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -42,9 +36,7 @@ spec:
|
||||
annotations: {{- toYaml .Values.init.podAnnotations | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
{{- include "superset.componentSelectorLabels" (dict "component" "init" "root" .) | nindent 8 }}
|
||||
job: {{ template "superset.fullname" . }}-init-db
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 8 }}
|
||||
|
||||
@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "superset.fullname" $ }}-celerybeat-pdb
|
||||
labels:
|
||||
app: {{ template "superset.name" $ }}-celerybeat
|
||||
chart: {{ template "superset.chart" $ }}
|
||||
release: {{ $.Release.Name }}
|
||||
heritage: {{ $.Release.Service }}
|
||||
{{- if $.Values.extraLabels }}
|
||||
{{- toYaml $.Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "celerybeat" "root" $) | nindent 4 }}
|
||||
spec:
|
||||
{{- if .minAvailable }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
|
||||
@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "superset.fullname" $ }}-flower-pdb
|
||||
labels:
|
||||
app: {{ template "superset.name" $ }}-flower
|
||||
chart: {{ template "superset.chart" $ }}
|
||||
release: {{ $.Release.Name }}
|
||||
heritage: {{ $.Release.Service }}
|
||||
{{- if $.Values.extraLabels }}
|
||||
{{- toYaml $.Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "flower" "root" $) | nindent 4 }}
|
||||
spec:
|
||||
{{- if .minAvailable }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
|
||||
@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "superset.fullname" $ }}-worker-pdb
|
||||
labels:
|
||||
app: {{ template "superset.name" $ }}-worker
|
||||
chart: {{ template "superset.chart" $ }}
|
||||
release: {{ $.Release.Name }}
|
||||
heritage: {{ $.Release.Service }}
|
||||
{{- if $.Values.extraLabels }}
|
||||
{{- toYaml $.Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "worker" "root" $) | nindent 4 }}
|
||||
spec:
|
||||
{{- if .minAvailable }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
|
||||
@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "superset.fullname" $ }}-ws-pdb
|
||||
labels:
|
||||
app: {{ template "superset.name" $ }}-ws
|
||||
chart: {{ template "superset.chart" $ }}
|
||||
release: {{ $.Release.Name }}
|
||||
heritage: {{ $.Release.Service }}
|
||||
{{- if $.Values.extraLabels }}
|
||||
{{- toYaml $.Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "websocket" "root" $) | nindent 4 }}
|
||||
spec:
|
||||
{{- if .minAvailable }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
|
||||
@@ -27,13 +27,7 @@ kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "superset.fullname" $ }}-pdb
|
||||
labels:
|
||||
app: {{ template "superset.name" $ }}
|
||||
chart: {{ template "superset.chart" $ }}
|
||||
release: {{ $.Release.Name }}
|
||||
heritage: {{ $.Release.Service }}
|
||||
{{- if $.Values.extraLabels }}
|
||||
{{- toYaml $.Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "web" "root" $) | nindent 4 }}
|
||||
spec:
|
||||
{{- if .minAvailable }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
|
||||
@@ -23,13 +23,7 @@ metadata:
|
||||
name: {{ template "superset.fullname" . }}-env
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.fullname" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: "{{ .Release.Name }}"
|
||||
heritage: "{{ .Release.Service }}"
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
REDIS_HOST: {{ tpl .Values.supersetNode.connections.redis_host . | quote }}
|
||||
|
||||
@@ -23,13 +23,7 @@ metadata:
|
||||
name: {{ template "superset.fullname" . }}-config
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.fullname" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: "{{ .Release.Name }}"
|
||||
heritage: "{{ .Release.Service }}"
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
superset_config.py: |
|
||||
|
||||
@@ -24,13 +24,7 @@ metadata:
|
||||
name: "{{ template "superset.fullname" . }}-ws-config"
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.fullname" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: "{{ .Release.Name }}"
|
||||
heritage: "{{ .Release.Service }}"
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
config.json: |
|
||||
|
||||
@@ -24,13 +24,7 @@ metadata:
|
||||
name: "{{ template "superset.fullname" . }}-flower"
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "flower" "root" .) | nindent 4 }}
|
||||
{{- with .Values.supersetCeleryFlower.service.annotations }}
|
||||
annotations: {{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -45,8 +39,7 @@ spec:
|
||||
nodePort: {{ .Values.supersetCeleryFlower.service.nodePort.http }}
|
||||
{{- end }}
|
||||
selector:
|
||||
app: {{ template "superset.name" . }}-flower
|
||||
release: {{ .Release.Name }}
|
||||
{{- include "supersetCeleryFlower.selectorLabels" . | nindent 4 }}
|
||||
{{- if .Values.supersetCeleryFlower.service.loadBalancerIP }}
|
||||
loadBalancerIP: {{ .Values.supersetCeleryFlower.service.loadBalancerIP }}
|
||||
{{- end }}
|
||||
|
||||
@@ -24,13 +24,7 @@ metadata:
|
||||
name: "{{ template "superset.fullname" . }}-ws"
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "websocket" "root" .) | nindent 4 }}
|
||||
{{- with .Values.supersetWebsockets.service.annotations }}
|
||||
annotations: {{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -45,8 +39,7 @@ spec:
|
||||
nodePort: {{ .Values.supersetWebsockets.service.nodePort.http }}
|
||||
{{- end }}
|
||||
selector:
|
||||
app: "{{ template "superset.name" . }}-ws"
|
||||
release: {{ .Release.Name }}
|
||||
{{- include "supersetWebsockets.selectorLabels" . | nindent 4 }}
|
||||
{{- if .Values.supersetWebsockets.service.loadBalancerIP }}
|
||||
loadBalancerIP: {{ .Values.supersetWebsockets.service.loadBalancerIP }}
|
||||
{{- end }}
|
||||
|
||||
@@ -23,13 +23,7 @@ metadata:
|
||||
name: {{ template "superset.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app: {{ template "superset.name" . }}
|
||||
chart: {{ template "superset.chart" . }}
|
||||
release: {{ .Release.Name }}
|
||||
heritage: {{ .Release.Service }}
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- include "superset.componentLabels" (dict "component" "web" "root" .) | nindent 4 }}
|
||||
{{- with .Values.service.annotations }}
|
||||
annotations: {{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -44,8 +38,7 @@ spec:
|
||||
nodePort: {{ .Values.service.nodePort.http }}
|
||||
{{- end }}
|
||||
selector:
|
||||
app: {{ template "superset.name" . }}
|
||||
release: {{ .Release.Name }}
|
||||
{{- include "supersetNode.selectorLabels" . | nindent 4 }}
|
||||
{{- if .Values.service.loadBalancerIP }}
|
||||
loadBalancerIP: {{ .Values.service.loadBalancerIP }}
|
||||
{{- end }}
|
||||
|
||||
@@ -24,17 +24,11 @@ metadata:
|
||||
name: {{ include "superset.serviceAccountName" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "superset.name" . }}
|
||||
helm.sh/chart: {{ include "superset.chart" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- include "superset.labels" . | nindent 4 }}
|
||||
{{- if semverCompare "> 1.6" .Capabilities.KubeVersion.GitVersion }}
|
||||
kubernetes.io/cluster-service: "true"
|
||||
{{- end }}
|
||||
addonmanager.kubernetes.io/mode: Reconcile
|
||||
{{- if .Values.extraLabels }}
|
||||
{{- toYaml .Values.extraLabels | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.serviceAccount.annotations }}
|
||||
annotations: {{- toYaml .Values.serviceAccount.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
|
||||
520
helm/superset/tests/labels_test.yaml
Normal file
520
helm/superset/tests/labels_test.yaml
Normal file
@@ -0,0 +1,520 @@
|
||||
#
|
||||
# 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: Label Consistency Tests
|
||||
templates:
|
||||
- deployment.yaml
|
||||
- deployment-worker.yaml
|
||||
- deployment-beat.yaml
|
||||
- deployment-flower.yaml
|
||||
- deployment-ws.yaml
|
||||
- service.yaml
|
||||
- service-ws.yaml
|
||||
- service-flower.yaml
|
||||
- init-job.yaml
|
||||
- ingress.yaml
|
||||
- configmap-superset.yaml
|
||||
- secret-superset-config.yaml
|
||||
- secret-ws.yaml
|
||||
- pdb.yaml
|
||||
- pdb-worker.yaml
|
||||
- pdb-beat.yaml
|
||||
- pdb-flower.yaml
|
||||
- pdb-ws.yaml
|
||||
|
||||
# These tests validate that Kubernetes recommended labels are consistently applied
|
||||
# across all chart resources per https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
|
||||
#
|
||||
# Required Labels (app.kubernetes.io/):
|
||||
# - name: The name of the application
|
||||
# - instance: A unique name identifying the instance of an application
|
||||
# - version: The current version of the application
|
||||
# - component: The component within the architecture
|
||||
# - part-of: The name of a higher level application this one is part of
|
||||
# - managed-by: The tool being used to manage the operation of an application
|
||||
#
|
||||
# Helm-specific Labels:
|
||||
# - helm.sh/chart: The chart name and version
|
||||
|
||||
tests:
|
||||
# =============================================================================
|
||||
# Main Deployment Labels
|
||||
# =============================================================================
|
||||
- it: should have all recommended labels on main deployment
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/version"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/managed-by"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/part-of"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["helm.sh/chart"]
|
||||
|
||||
- it: should have correct component label on main deployment
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: web
|
||||
|
||||
- it: should have part-of label set to superset on main deployment
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/part-of"]
|
||||
value: superset
|
||||
|
||||
# =============================================================================
|
||||
# Worker Deployment Labels
|
||||
# =============================================================================
|
||||
- it: should have all recommended labels on worker deployment
|
||||
template: deployment-worker.yaml
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/version"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/managed-by"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/part-of"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
|
||||
- it: should have correct component label on worker deployment
|
||||
template: deployment-worker.yaml
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: worker
|
||||
|
||||
# =============================================================================
|
||||
# Celery Beat Deployment Labels
|
||||
# =============================================================================
|
||||
- it: should have all recommended labels on celerybeat deployment
|
||||
template: deployment-beat.yaml
|
||||
set:
|
||||
supersetCeleryBeat.enabled: true
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
|
||||
- it: should have correct component label on celerybeat deployment
|
||||
template: deployment-beat.yaml
|
||||
set:
|
||||
supersetCeleryBeat.enabled: true
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: celerybeat
|
||||
|
||||
# =============================================================================
|
||||
# Flower Deployment Labels
|
||||
# =============================================================================
|
||||
- it: should have all recommended labels on flower deployment
|
||||
template: deployment-flower.yaml
|
||||
set:
|
||||
supersetCeleryFlower.enabled: true
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
|
||||
- it: should have correct component label on flower deployment
|
||||
template: deployment-flower.yaml
|
||||
set:
|
||||
supersetCeleryFlower.enabled: true
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: flower
|
||||
|
||||
# =============================================================================
|
||||
# WebSocket Deployment Labels
|
||||
# =============================================================================
|
||||
- it: should have all recommended labels on websocket deployment
|
||||
template: deployment-ws.yaml
|
||||
set:
|
||||
supersetWebsockets.enabled: true
|
||||
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
|
||||
- it: should have correct component label on websocket deployment
|
||||
template: deployment-ws.yaml
|
||||
set:
|
||||
supersetWebsockets.enabled: true
|
||||
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: websocket
|
||||
|
||||
# =============================================================================
|
||||
# Service Labels
|
||||
# =============================================================================
|
||||
- it: should have all recommended labels on main service
|
||||
template: service.yaml
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
|
||||
- it: should have correct component label on main service
|
||||
template: service.yaml
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: web
|
||||
|
||||
- it: should have all recommended labels on websocket service
|
||||
template: service-ws.yaml
|
||||
set:
|
||||
supersetWebsockets.enabled: true
|
||||
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
|
||||
- it: should have correct component label on websocket service
|
||||
template: service-ws.yaml
|
||||
set:
|
||||
supersetWebsockets.enabled: true
|
||||
supersetWebsockets.config.jwtSecret: "test-secret-for-unit-test"
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: websocket
|
||||
|
||||
- it: should have all recommended labels on flower service
|
||||
template: service-flower.yaml
|
||||
set:
|
||||
supersetCeleryFlower.enabled: true
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
|
||||
- it: should have correct component label on flower service
|
||||
template: service-flower.yaml
|
||||
set:
|
||||
supersetCeleryFlower.enabled: true
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: flower
|
||||
|
||||
# =============================================================================
|
||||
# Init Job Labels
|
||||
# =============================================================================
|
||||
- it: should have all recommended labels on init job
|
||||
template: init-job.yaml
|
||||
set:
|
||||
init.enabled: true
|
||||
init.createAdmin: true
|
||||
init.adminUser.password: "test-password"
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
|
||||
- it: should have correct component label on init job
|
||||
template: init-job.yaml
|
||||
set:
|
||||
init.enabled: true
|
||||
init.createAdmin: true
|
||||
init.adminUser.password: "test-password"
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: init
|
||||
|
||||
# =============================================================================
|
||||
# Ingress Labels
|
||||
# =============================================================================
|
||||
- it: should have all recommended labels on ingress
|
||||
template: ingress.yaml
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.hosts:
|
||||
- host: superset.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
|
||||
- it: should have correct component label on ingress
|
||||
template: ingress.yaml
|
||||
set:
|
||||
ingress.enabled: true
|
||||
ingress.hosts:
|
||||
- host: superset.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: ingress
|
||||
|
||||
# =============================================================================
|
||||
# Selector Label Consistency
|
||||
#
|
||||
# These use value assertions (not isNotNull) on purpose: a missing/misscoped
|
||||
# release name renders as the string "<no value>", which is non-null and would
|
||||
# silently pass isNotNull. Asserting the concrete value catches that class of
|
||||
# bug, and asserting the pod template labels equal the selector guards the
|
||||
# immutable spec.selector.matchLabels <-> pod label invariant.
|
||||
# =============================================================================
|
||||
- it: should set selector matchLabels to concrete values on main deployment
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/name"]
|
||||
value: superset
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/instance"]
|
||||
value: RELEASE-NAME
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/component"]
|
||||
value: web
|
||||
|
||||
- it: should match pod template labels to the selector on main deployment
|
||||
template: deployment.yaml
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.metadata.labels["app.kubernetes.io/name"]
|
||||
value: superset
|
||||
- equal:
|
||||
path: spec.template.metadata.labels["app.kubernetes.io/instance"]
|
||||
value: RELEASE-NAME
|
||||
- equal:
|
||||
path: spec.template.metadata.labels["app.kubernetes.io/component"]
|
||||
value: web
|
||||
|
||||
- it: should set selector matchLabels to concrete values on worker deployment
|
||||
template: deployment-worker.yaml
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/instance"]
|
||||
value: RELEASE-NAME
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/component"]
|
||||
value: worker
|
||||
|
||||
- it: should match pod template labels to the selector on worker deployment
|
||||
template: deployment-worker.yaml
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.metadata.labels["app.kubernetes.io/instance"]
|
||||
value: RELEASE-NAME
|
||||
- equal:
|
||||
path: spec.template.metadata.labels["app.kubernetes.io/component"]
|
||||
value: worker
|
||||
|
||||
# =============================================================================
|
||||
# Extra Labels Support
|
||||
# =============================================================================
|
||||
- it: should include extraLabels when specified
|
||||
template: deployment.yaml
|
||||
set:
|
||||
extraLabels:
|
||||
custom-label: custom-value
|
||||
environment: production
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels.custom-label
|
||||
value: custom-value
|
||||
- equal:
|
||||
path: metadata.labels.environment
|
||||
value: production
|
||||
|
||||
- it: should include extraLabels in service
|
||||
template: service.yaml
|
||||
set:
|
||||
extraLabels:
|
||||
custom-label: custom-value
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels.custom-label
|
||||
value: custom-value
|
||||
|
||||
# =============================================================================
|
||||
# ConfigMap / Secret Labels
|
||||
# =============================================================================
|
||||
- it: should have recommended labels on extra-config configmap
|
||||
template: configmap-superset.yaml
|
||||
set:
|
||||
extraConfigs:
|
||||
custom.py: "FOO = 1"
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
value: RELEASE-NAME
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/managed-by"]
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/part-of"]
|
||||
value: superset
|
||||
|
||||
- it: should have recommended labels on superset config secret
|
||||
template: secret-superset-config.yaml
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
value: RELEASE-NAME
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/part-of"]
|
||||
value: superset
|
||||
|
||||
- it: should have recommended labels on websocket config secret
|
||||
template: secret-ws.yaml
|
||||
set:
|
||||
supersetWebsockets.enabled: true
|
||||
asserts:
|
||||
- isNotNull:
|
||||
path: metadata.labels["app.kubernetes.io/name"]
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
value: RELEASE-NAME
|
||||
|
||||
# =============================================================================
|
||||
# PodDisruptionBudget Labels (metadata must match the selector)
|
||||
# =============================================================================
|
||||
- it: should have recommended labels and matching selector on main pdb
|
||||
template: pdb.yaml
|
||||
set:
|
||||
supersetNode.podDisruptionBudget.enabled: true
|
||||
supersetNode.podDisruptionBudget.maxUnavailable: null
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/instance"]
|
||||
value: RELEASE-NAME
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: web
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/instance"]
|
||||
value: RELEASE-NAME
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/component"]
|
||||
value: web
|
||||
|
||||
- it: should set correct component on worker pdb
|
||||
template: pdb-worker.yaml
|
||||
set:
|
||||
supersetWorker.podDisruptionBudget.enabled: true
|
||||
supersetWorker.podDisruptionBudget.maxUnavailable: null
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: worker
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/component"]
|
||||
value: worker
|
||||
|
||||
- it: should set correct component on celerybeat pdb
|
||||
template: pdb-beat.yaml
|
||||
set:
|
||||
supersetCeleryBeat.podDisruptionBudget.enabled: true
|
||||
supersetCeleryBeat.podDisruptionBudget.maxUnavailable: null
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: celerybeat
|
||||
|
||||
- it: should set correct component on flower pdb
|
||||
template: pdb-flower.yaml
|
||||
set:
|
||||
supersetCeleryFlower.podDisruptionBudget.enabled: true
|
||||
supersetCeleryFlower.podDisruptionBudget.maxUnavailable: null
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: flower
|
||||
|
||||
- it: should set correct component on websocket pdb
|
||||
template: pdb-ws.yaml
|
||||
set:
|
||||
supersetWebsockets.enabled: true
|
||||
supersetWebsockets.podDisruptionBudget.enabled: true
|
||||
supersetWebsockets.podDisruptionBudget.maxUnavailable: null
|
||||
asserts:
|
||||
- equal:
|
||||
path: metadata.labels["app.kubernetes.io/component"]
|
||||
value: websocket
|
||||
- equal:
|
||||
path: spec.selector.matchLabels["app.kubernetes.io/component"]
|
||||
value: websocket
|
||||
|
||||
- it: should use recommended labels on init job pod template
|
||||
template: init-job.yaml
|
||||
set:
|
||||
init.enabled: true
|
||||
asserts:
|
||||
- equal:
|
||||
path: spec.template.metadata.labels["app.kubernetes.io/instance"]
|
||||
value: RELEASE-NAME
|
||||
- equal:
|
||||
path: spec.template.metadata.labels["app.kubernetes.io/component"]
|
||||
value: init
|
||||
- isNotNull:
|
||||
path: spec.template.metadata.labels.job
|
||||
@@ -54,7 +54,7 @@ dependencies = [
|
||||
"cryptography>=48.0.0, <49.0.0",
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
"flask>=2.2.5, <4.0.0",
|
||||
"flask-appbuilder>=5.2.1, <6.0.0",
|
||||
"flask-appbuilder>=5.2.2, <6.0.0",
|
||||
"flask-caching>=2.1.0, <3",
|
||||
"flask-compress>=1.13, <2.0",
|
||||
"flask-talisman>=1.0.0, <2.0",
|
||||
@@ -64,7 +64,7 @@ dependencies = [
|
||||
"flask-wtf>=1.3.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet<=3.5.1, >=3.5.1",
|
||||
"gunicorn>=25.3.0, <26; sys_platform != 'win32'",
|
||||
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
"holidays>=0.45, <1",
|
||||
@@ -101,7 +101,7 @@ dependencies = [
|
||||
"pyyaml>=6.0.3, <7.0.0",
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=5.0.0, <6.0",
|
||||
"rison>=2.0.0, <3.0",
|
||||
"rison>=2.0.1, <3.0",
|
||||
"selenium>=4.45.0, <5.0",
|
||||
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
@@ -174,11 +174,11 @@ hive = [
|
||||
]
|
||||
impala = ["impyla>0.16.2, <0.23"]
|
||||
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
|
||||
kylin = ["kylinpy>=2.8.1, <2.9"]
|
||||
kylin = ["kylinpy>=2.8.4, <2.9"]
|
||||
mssql = ["pymssql>=2.3.13, <3"]
|
||||
# motherduck is an alias for duckdb - MotherDuck works via the duckdb driver
|
||||
motherduck = ["apache-superset[duckdb]"]
|
||||
mysql = ["mysqlclient>=2.1.0, <3"]
|
||||
mysql = ["mysqlclient>=2.2.8, <3"]
|
||||
ocient = [
|
||||
"sqlalchemy-ocient>=1.0.0",
|
||||
"pyocient>=1.0.15, <4",
|
||||
@@ -216,7 +216,7 @@ netezza = ["nzalchemy>=11.0.2, < 11.2"]
|
||||
starrocks = ["starrocks>=1.3.3, <2"]
|
||||
doris = ["pydoris>=1.0.0, <2.0.0"]
|
||||
oceanbase = ["oceanbase_py>=0.0.1.2"]
|
||||
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.5"]
|
||||
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.8"]
|
||||
development = [
|
||||
# no bounds for apache-superset-extensions-cli until a stable version
|
||||
"apache-superset-extensions-cli",
|
||||
|
||||
@@ -122,7 +122,7 @@ flask==2.3.3
|
||||
# flask-session
|
||||
# flask-sqlalchemy
|
||||
# flask-wtf
|
||||
flask-appbuilder==5.2.1
|
||||
flask-appbuilder==5.2.2
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
@@ -169,7 +169,7 @@ greenlet==3.5.1
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
gunicorn==25.3.0
|
||||
gunicorn==26.0.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
h11==0.16.0
|
||||
# via wsproto
|
||||
@@ -369,7 +369,7 @@ rfc3339-validator==0.1.4
|
||||
# via openapi-schema-validator
|
||||
rich==13.9.4
|
||||
# via flask-limiter
|
||||
rison==2.0.0
|
||||
rison==2.0.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
rpds-py==0.25.0
|
||||
# via
|
||||
|
||||
@@ -263,7 +263,7 @@ flask==2.3.3
|
||||
# flask-sqlalchemy
|
||||
# flask-testing
|
||||
# flask-wtf
|
||||
flask-appbuilder==5.2.1
|
||||
flask-appbuilder==5.2.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -393,7 +393,7 @@ grpcio==1.81.1
|
||||
# grpcio-status
|
||||
grpcio-status==1.60.1
|
||||
# via google-api-core
|
||||
gunicorn==25.3.0
|
||||
gunicorn==26.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -572,7 +572,7 @@ msgspec==0.19.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-session
|
||||
mysqlclient==2.2.6
|
||||
mysqlclient==2.2.8
|
||||
# via apache-superset
|
||||
nh3==0.3.5
|
||||
# via
|
||||
@@ -913,7 +913,7 @@ rich==13.9.4
|
||||
# rich-rst
|
||||
rich-rst==1.3.1
|
||||
# via cyclopts
|
||||
rison==2.0.0
|
||||
rison==2.0.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
1205
superset-frontend/package-lock.json
generated
1205
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -192,13 +192,14 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"markdown-to-jsx": "^9.8.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.15",
|
||||
"nanoid": "^5.1.16",
|
||||
"ol": "^10.9.0",
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
@@ -239,8 +240,7 @@
|
||||
"use-query-params": "^2.2.2",
|
||||
"uuid": "^14.0.1",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yargs": "^18.0.0",
|
||||
"lodash-es": "^4.17.21"
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.29.7",
|
||||
@@ -265,9 +265,9 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.4.5",
|
||||
"@storybook/addon-links": "10.4.4",
|
||||
"@storybook/react-webpack5": "10.4.4",
|
||||
"@storybook/addon-docs": "10.4.6",
|
||||
"@storybook/addon-links": "10.4.6",
|
||||
"@storybook/react-webpack5": "10.4.6",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.43",
|
||||
@@ -283,6 +283,7 @@
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/react": "^18.3.0",
|
||||
@@ -303,13 +304,12 @@
|
||||
"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.38",
|
||||
"baseline-browser-mapping": "^2.10.40",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"css-minimizer-webpack-plugin": "^8.0.0",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
@@ -342,6 +342,7 @@
|
||||
"lerna": "^9.0.4",
|
||||
"lightningcss": "^1.32.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.6.1",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"po2json": "^0.4.5",
|
||||
@@ -358,7 +359,6 @@
|
||||
"storybook": "10.4.6",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.4",
|
||||
@@ -366,14 +366,13 @@
|
||||
"unzipper": "^0.12.5",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack": "^5.108.3",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-dev-server": "^5.2.5",
|
||||
"webpack-manifest-plugin": "^6.0.1",
|
||||
"webpack-sources": "^3.5.0",
|
||||
"webpack-visualizer-plugin2": "^2.0.0",
|
||||
"@types/lodash-es": "^4.17.12"
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ace-builds": "^1.41.0",
|
||||
|
||||
@@ -237,6 +237,27 @@ export interface SupersetSpecificTokens {
|
||||
* Fallback: transparent
|
||||
*/
|
||||
buttonSecondaryActiveBorderColor?: string;
|
||||
|
||||
// Component flexibility tokens (sizing, radius, behavior)
|
||||
selectOptionActiveOutline?: boolean;
|
||||
labelBorderRadius?: number;
|
||||
buttonControlHeight?: number;
|
||||
buttonControlHeightSM?: number;
|
||||
buttonControlHeightXS?: number;
|
||||
buttonPaddingInline?: number;
|
||||
buttonPaddingInlineSM?: number;
|
||||
buttonFontSize?: number;
|
||||
buttonBorderRadius?: number;
|
||||
buttonStyleMap?: Record<
|
||||
string,
|
||||
{ type?: string; variant?: string; color?: string }
|
||||
>;
|
||||
|
||||
// Dashboard tile tokens (opt-in, fallbacks: colorBgContainer bg, no border, borderRadius, hairline box-shadow)
|
||||
dashboardTileBg?: string;
|
||||
dashboardTileBorder?: string;
|
||||
dashboardTileBorderRadius?: number;
|
||||
dashboardTileBoxShadow?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -713,4 +713,5 @@ export interface DataColumnMeta {
|
||||
isChildColumn?: boolean;
|
||||
description?: string;
|
||||
currencyCodeColumn?: string;
|
||||
isFilterable?: boolean;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"xss": "^1.0.15",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
||||
@@ -125,6 +125,11 @@ InteractiveButton.argTypes = {
|
||||
options: buttonSizes,
|
||||
control: { type: 'select' },
|
||||
},
|
||||
styleConfig: {
|
||||
description:
|
||||
'Optional visual overrides (controlHeight, paddingInline, fontSize, fontWeight, borderRadius, ctaMinWidth, ctaMinHeight, iconGap).',
|
||||
control: { type: 'object' },
|
||||
},
|
||||
target: {
|
||||
name: TARGETS.label,
|
||||
control: { type: 'select' },
|
||||
|
||||
@@ -186,6 +186,68 @@ test('getSecondaryButtonHoverStyles supports partial token overrides', () => {
|
||||
expect(hoverStyles['&:active'].backgroundColor).toBe('#99d3df !important');
|
||||
});
|
||||
|
||||
test('styleConfig overrides theme defaults', () => {
|
||||
const { getByRole } = render(
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
styleConfig={{
|
||||
controlHeight: 50,
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
paddingInline: 30,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
Custom
|
||||
</Button>,
|
||||
);
|
||||
expect(getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('styleConfig partial override merges with defaults', () => {
|
||||
const { getByRole } = render(
|
||||
<Button buttonStyle="primary" styleConfig={{ controlHeight: 44 }}>
|
||||
Partial
|
||||
</Button>,
|
||||
);
|
||||
expect(getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('xsmall size resolves with theme tokens', () => {
|
||||
const { getByRole } = render(
|
||||
<Button buttonSize="xsmall" buttonStyle="primary">
|
||||
XSmall
|
||||
</Button>,
|
||||
);
|
||||
expect(getByRole('button')).toBeInTheDocument();
|
||||
expect(getByRole('button')).toHaveClass('superset-button');
|
||||
});
|
||||
|
||||
test('small size resolves with theme tokens', () => {
|
||||
const { getByRole } = render(
|
||||
<Button buttonSize="small" buttonStyle="primary">
|
||||
Small
|
||||
</Button>,
|
||||
);
|
||||
expect(getByRole('button')).toBeInTheDocument();
|
||||
expect(getByRole('button')).toHaveClass('superset-button');
|
||||
});
|
||||
|
||||
test('primary buttonStyle applies ant-btn-primary class', () => {
|
||||
const { getByRole } = render(<Button buttonStyle="primary">Primary</Button>);
|
||||
expect(getByRole('button')).toHaveClass('ant-btn-primary');
|
||||
});
|
||||
|
||||
test('danger buttonStyle applies ant-btn-dangerous class', () => {
|
||||
const { getByRole } = render(<Button buttonStyle="danger">Danger</Button>);
|
||||
expect(getByRole('button')).toHaveClass('ant-btn-dangerous');
|
||||
});
|
||||
|
||||
test('link buttonStyle applies ant-btn-link class', () => {
|
||||
const { getByRole } = render(<Button buttonStyle="link">Link</Button>);
|
||||
expect(getByRole('button')).toHaveClass('ant-btn-link');
|
||||
});
|
||||
|
||||
test('getSecondaryButtonStyle falls back when tokens are empty strings', () => {
|
||||
const mockTheme = {
|
||||
colorPrimary: '#2893B3',
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { SupersetTheme } from '@apache-superset/core/theme';
|
||||
import type {
|
||||
ButtonColorType,
|
||||
ButtonProps,
|
||||
ButtonStyleConfig,
|
||||
ButtonStyle,
|
||||
ButtonType,
|
||||
ButtonVariantType,
|
||||
@@ -84,14 +85,13 @@ export const getSecondaryButtonHoverStyles = (theme: SupersetTheme) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const BUTTON_STYLE_MAP: Record<
|
||||
ButtonStyle,
|
||||
{
|
||||
type?: ButtonType;
|
||||
variant?: ButtonVariantType;
|
||||
color?: ButtonColorType;
|
||||
}
|
||||
> = {
|
||||
type ButtonStyleMapping = {
|
||||
type?: ButtonType;
|
||||
variant?: ButtonVariantType;
|
||||
color?: ButtonColorType;
|
||||
};
|
||||
|
||||
const BUTTON_STYLE_MAP: Record<ButtonStyle, ButtonStyleMapping> = {
|
||||
primary: { type: 'primary', variant: 'solid', color: 'primary' },
|
||||
secondary: { variant: 'filled', color: 'primary' },
|
||||
tertiary: { variant: 'outlined', color: 'default' },
|
||||
@@ -113,30 +113,56 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
|
||||
href,
|
||||
showMarginRight = true,
|
||||
icon,
|
||||
styleConfig,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const theme = useTheme();
|
||||
const { fontSizeSM, fontWeightStrong } = theme;
|
||||
const { fontWeightStrong } = theme;
|
||||
const btnFontSize = theme.buttonFontSize ?? theme.fontSizeSM;
|
||||
|
||||
let height = 32;
|
||||
let padding = 18;
|
||||
const resolvedStyleMap: Record<ButtonStyle, ButtonStyleMapping> =
|
||||
theme.buttonStyleMap
|
||||
? (Object.fromEntries(
|
||||
Object.entries(BUTTON_STYLE_MAP).map(([key, value]) => [
|
||||
key,
|
||||
{ ...value, ...theme.buttonStyleMap?.[key as ButtonStyle] },
|
||||
]),
|
||||
) as Record<ButtonStyle, ButtonStyleMapping>)
|
||||
: BUTTON_STYLE_MAP;
|
||||
|
||||
let defaultHeight = theme.buttonControlHeight ?? 32;
|
||||
let defaultPaddingInline = theme.buttonPaddingInline ?? 18;
|
||||
let defaultBorderRadius = theme.buttonBorderRadius ?? theme.borderRadius;
|
||||
if (buttonSize === 'xsmall') {
|
||||
height = 22;
|
||||
padding = 5;
|
||||
defaultHeight = theme.buttonControlHeightXS ?? 22;
|
||||
defaultPaddingInline = 5;
|
||||
defaultBorderRadius = theme.buttonBorderRadius ?? theme.borderRadiusSM;
|
||||
} else if (buttonSize === 'small') {
|
||||
height = 30;
|
||||
padding = 10;
|
||||
defaultHeight = theme.buttonControlHeightSM ?? 30;
|
||||
defaultPaddingInline = theme.buttonPaddingInlineSM ?? 10;
|
||||
defaultBorderRadius = theme.buttonBorderRadius ?? theme.borderRadiusSM;
|
||||
}
|
||||
if (buttonStyle === 'link') {
|
||||
padding = 4;
|
||||
defaultPaddingInline = 4;
|
||||
}
|
||||
|
||||
const resolvedStyleConfig: Required<ButtonStyleConfig> = {
|
||||
controlHeight: styleConfig?.controlHeight ?? defaultHeight,
|
||||
paddingInline: styleConfig?.paddingInline ?? defaultPaddingInline,
|
||||
fontSize: styleConfig?.fontSize ?? btnFontSize,
|
||||
fontWeight: styleConfig?.fontWeight ?? fontWeightStrong,
|
||||
ctaMinWidth: styleConfig?.ctaMinWidth ?? theme.sizeUnit * 36,
|
||||
ctaMinHeight: styleConfig?.ctaMinHeight ?? theme.sizeUnit * 8,
|
||||
iconGap: styleConfig?.iconGap ?? theme.sizeUnit * 2,
|
||||
borderRadius: styleConfig?.borderRadius ?? defaultBorderRadius,
|
||||
};
|
||||
|
||||
const {
|
||||
type: antdType = 'default',
|
||||
variant,
|
||||
color,
|
||||
} = BUTTON_STYLE_MAP[buttonStyle ?? 'primary'];
|
||||
} = resolvedStyleMap[buttonStyle ?? 'primary'] ?? BUTTON_STYLE_MAP.primary;
|
||||
|
||||
const element = children as ReactElement;
|
||||
|
||||
@@ -148,7 +174,9 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
|
||||
renderedChildren = Children.toArray(children);
|
||||
}
|
||||
const firstChildMargin =
|
||||
showMarginRight && renderedChildren.length > 1 ? theme.sizeUnit * 2 : 0;
|
||||
showMarginRight && renderedChildren.length > 1
|
||||
? resolvedStyleConfig.iconGap
|
||||
: 0;
|
||||
|
||||
const effectiveButtonStyle: ButtonStyle = buttonStyle ?? 'primary';
|
||||
|
||||
@@ -167,11 +195,10 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
|
||||
variant={variant}
|
||||
danger={effectiveButtonStyle === 'danger'}
|
||||
color={color}
|
||||
// Static class names for embedded-dashboard CSS targeting
|
||||
className={cx(
|
||||
className,
|
||||
'superset-button',
|
||||
// A static class name containing the button style is available to
|
||||
// support customizing button styles in embedded dashboards.
|
||||
`superset-button-${buttonStyle}`,
|
||||
{ cta: !!cta },
|
||||
)}
|
||||
@@ -180,12 +207,13 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
fontSize: fontSizeSM,
|
||||
fontWeight: fontWeightStrong,
|
||||
height,
|
||||
padding: `0px ${padding}px`,
|
||||
minWidth: cta ? theme.sizeUnit * 36 : undefined,
|
||||
minHeight: cta ? theme.sizeUnit * 8 : undefined,
|
||||
fontSize: resolvedStyleConfig.fontSize,
|
||||
fontWeight: resolvedStyleConfig.fontWeight,
|
||||
height: resolvedStyleConfig.controlHeight,
|
||||
padding: `0px ${resolvedStyleConfig.paddingInline}px`,
|
||||
borderRadius: resolvedStyleConfig.borderRadius,
|
||||
minWidth: cta ? resolvedStyleConfig.ctaMinWidth : undefined,
|
||||
minHeight: cta ? resolvedStyleConfig.ctaMinHeight : undefined,
|
||||
marginLeft: 0,
|
||||
'& + .superset-button:not(.ant-btn-compact-item)': {
|
||||
marginLeft: theme.sizeUnit * 2,
|
||||
|
||||
@@ -40,6 +40,17 @@ export type ButtonStyle =
|
||||
|
||||
export type ButtonSize = 'default' | 'small' | 'xsmall';
|
||||
|
||||
export type ButtonStyleConfig = {
|
||||
controlHeight?: number;
|
||||
paddingInline?: number;
|
||||
fontSize?: number;
|
||||
fontWeight?: number;
|
||||
ctaMinWidth?: number;
|
||||
ctaMinHeight?: number;
|
||||
iconGap?: number;
|
||||
borderRadius?: number;
|
||||
};
|
||||
|
||||
export type ButtonProps = Omit<AntdButtonProps, 'css'> & {
|
||||
placement?: TooltipPlacement;
|
||||
tooltip?: ReactNode;
|
||||
@@ -49,4 +60,5 @@ export type ButtonProps = Omit<AntdButtonProps, 'css'> & {
|
||||
cta?: boolean;
|
||||
showMarginRight?: boolean;
|
||||
icon?: ReactNode;
|
||||
styleConfig?: ButtonStyleConfig;
|
||||
};
|
||||
|
||||
@@ -60,6 +60,11 @@ InteractiveDropdownButton.args = {
|
||||
};
|
||||
|
||||
InteractiveDropdownButton.argTypes = {
|
||||
styleConfig: {
|
||||
description:
|
||||
'Optional visual overrides (controlHeight, fontSize, fontWeight, boxShadow).',
|
||||
control: { type: 'object' },
|
||||
},
|
||||
placement: {
|
||||
defaultValue: 'top',
|
||||
control: { type: 'select' },
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 { fireEvent, render, waitFor } from '@superset-ui/core/spec';
|
||||
import { DropdownButton } from '.';
|
||||
|
||||
const menuProps = { items: [{ key: '1', label: 'Item 1' }] };
|
||||
|
||||
test('renders without crashing when no styleConfig is provided', () => {
|
||||
const { container } = render(
|
||||
<DropdownButton menu={menuProps}>Click</DropdownButton>,
|
||||
);
|
||||
expect(container.querySelector('.ant-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders without crashing with full styleConfig', () => {
|
||||
const { container } = render(
|
||||
<DropdownButton
|
||||
menu={menuProps}
|
||||
styleConfig={{
|
||||
controlHeight: 40,
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
Click
|
||||
</DropdownButton>,
|
||||
);
|
||||
expect(container.querySelector('.ant-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders tooltip when tooltip prop is provided', async () => {
|
||||
const { getByText } = render(
|
||||
<DropdownButton menu={menuProps} tooltip="My Tooltip">
|
||||
Click
|
||||
</DropdownButton>,
|
||||
);
|
||||
fireEvent.mouseEnter(getByText('Click'));
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[role="tooltip"]')).toHaveTextContent(
|
||||
'My Tooltip',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not render tooltip wrapper when tooltip is not provided', () => {
|
||||
const { container } = render(
|
||||
<DropdownButton menu={menuProps}>Click</DropdownButton>,
|
||||
);
|
||||
expect(container.querySelector('[id$="-tooltip"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('passes button type to underlying Dropdown.Button', () => {
|
||||
const { container } = render(
|
||||
<DropdownButton menu={menuProps} type="primary">
|
||||
Click
|
||||
</DropdownButton>,
|
||||
);
|
||||
expect(container.querySelector('.ant-btn-primary')).toBeInTheDocument();
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export const DropdownButton = ({
|
||||
tooltip,
|
||||
tooltipPlacement,
|
||||
children,
|
||||
styleConfig,
|
||||
...rest
|
||||
}: DropdownButtonProps) => {
|
||||
const theme = useTheme();
|
||||
@@ -57,10 +58,14 @@ export const DropdownButton = ({
|
||||
defaultBtnCss,
|
||||
css`
|
||||
.ant-btn {
|
||||
height: 30px;
|
||||
box-shadow: none;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
height: ${styleConfig?.controlHeight ??
|
||||
theme.buttonControlHeightSM ??
|
||||
30}px;
|
||||
box-shadow: ${styleConfig?.boxShadow ?? 'none'};
|
||||
font-size: ${styleConfig?.fontSize ??
|
||||
theme.buttonFontSize ??
|
||||
theme.fontSizeSM}px;
|
||||
font-weight: ${styleConfig?.fontWeight ?? theme.fontWeightStrong};
|
||||
}
|
||||
`,
|
||||
]}
|
||||
@@ -85,4 +90,4 @@ export const DropdownButton = ({
|
||||
return button;
|
||||
};
|
||||
|
||||
export type { DropdownButtonProps };
|
||||
export type { DropdownButtonProps, DropdownButtonStyleConfig } from './types';
|
||||
|
||||
@@ -21,7 +21,15 @@ import { type ComponentProps } from 'react';
|
||||
import { Dropdown } from 'antd';
|
||||
import type { TooltipPlacement } from '../Tooltip/types';
|
||||
|
||||
export type DropdownButtonStyleConfig = {
|
||||
controlHeight?: number;
|
||||
fontSize?: number;
|
||||
fontWeight?: number;
|
||||
boxShadow?: string;
|
||||
};
|
||||
|
||||
export type DropdownButtonProps = ComponentProps<typeof Dropdown.Button> & {
|
||||
tooltip?: string;
|
||||
tooltipPlacement?: TooltipPlacement;
|
||||
styleConfig?: DropdownButtonStyleConfig;
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ export const LabeledErrorBoundInput = ({
|
||||
isValidating ? 'validating' : hasError ? 'error' : 'success'
|
||||
}
|
||||
help={errorMessage || helpText}
|
||||
hasFeedback={!!hasError}
|
||||
hasFeedback={isValidating || !!hasError}
|
||||
>
|
||||
{visibilityToggle || props.name === 'password' ? (
|
||||
<StyledInputPassword
|
||||
|
||||
@@ -31,5 +31,6 @@ export interface LabeledErrorBoundInputProps {
|
||||
id?: string;
|
||||
classname?: string;
|
||||
visibilityToggle?: boolean;
|
||||
isValidating?: boolean;
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const Label = forwardRef<HTMLSpanElement, LabelProps>((props, ref) => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background-color: ${backgroundColor};
|
||||
border-radius: 8px;
|
||||
border-radius: ${theme.labelBorderRadius ?? 8}px;
|
||||
border-color: ${borderColor};
|
||||
padding: 0.35em 0.8em;
|
||||
line-height: 1;
|
||||
|
||||
@@ -32,10 +32,11 @@ import { CertifiedBadge } from '../CertifiedBadge';
|
||||
import { Button } from '../Button';
|
||||
|
||||
export const menuTriggerStyles = (theme: SupersetTheme) => css`
|
||||
width: ${theme.sizeUnit * 8}px;
|
||||
height: ${theme.sizeUnit * 8}px;
|
||||
width: ${theme.buttonControlHeight ?? theme.sizeUnit * 8}px;
|
||||
height: ${theme.buttonControlHeight ?? theme.sizeUnit * 8}px;
|
||||
padding: 0;
|
||||
border: 1px solid ${theme.colorPrimary};
|
||||
border-radius: ${theme.buttonBorderRadius ?? theme.borderRadius}px;
|
||||
|
||||
&.ant-btn > span.anticon {
|
||||
line-height: 0;
|
||||
|
||||
@@ -398,6 +398,25 @@ 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 paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'a, b, c , d',
|
||||
},
|
||||
});
|
||||
fireEvent(input, paste);
|
||||
await waitFor(async () => {
|
||||
const values = await findAllSelectValues();
|
||||
expect(values.length).toBe(4);
|
||||
expect(values[0]).toHaveTextContent('a');
|
||||
expect(values[1]).toHaveTextContent('b');
|
||||
expect(values[2]).toHaveTextContent('c');
|
||||
expect(values[3]).toHaveTextContent('d');
|
||||
});
|
||||
});
|
||||
|
||||
test('renders a custom label', async () => {
|
||||
const loadOptions = jest.fn(async () => ({
|
||||
data: [
|
||||
|
||||
@@ -694,7 +694,14 @@ const AsyncSelect = forwardRef(
|
||||
}
|
||||
} else {
|
||||
const token = tokenSeparators.find(token => pastedText.includes(token));
|
||||
const array = token ? uniq(pastedText.split(token)) : [pastedText];
|
||||
const array = token
|
||||
? uniq(
|
||||
pastedText
|
||||
.split(token)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
: [pastedText.trim()].filter(Boolean);
|
||||
const values = (
|
||||
await Promise.all(array.map(item => getPastedTextValue(item)))
|
||||
).filter(item => item !== undefined) as AntdLabeledValue[];
|
||||
|
||||
@@ -378,6 +378,23 @@ test('removes duplicated values', async () => {
|
||||
expect(values[3]).toHaveTextContent('d');
|
||||
});
|
||||
|
||||
test('trims whitespace from pasted comma-separated values', async () => {
|
||||
render(<Select {...defaultProps} mode="multiple" allowNewOptions />);
|
||||
const input = getElementByClassName('.ant-select-selection-search-input');
|
||||
const paste = createEvent.paste(input, {
|
||||
clipboardData: {
|
||||
getData: () => 'a, b, c , d',
|
||||
},
|
||||
});
|
||||
fireEvent(input, paste);
|
||||
const values = await findAllSelectValues();
|
||||
expect(values.length).toBe(4);
|
||||
expect(values[0]).toHaveTextContent('a');
|
||||
expect(values[1]).toHaveTextContent('b');
|
||||
expect(values[2]).toHaveTextContent('c');
|
||||
expect(values[3]).toHaveTextContent('d');
|
||||
});
|
||||
|
||||
test('renders a custom label', async () => {
|
||||
const options = [
|
||||
{ value: 'John', label: <h1>John</h1> },
|
||||
|
||||
@@ -45,10 +45,11 @@ export const StyledContainer = styled.div<{ headerPosition: string }>`
|
||||
export const StyledSelect = styled(Select, {
|
||||
shouldForwardProp: prop => prop !== 'headerPosition' && prop !== 'oneLine',
|
||||
})<{ headerPosition?: string; oneLine?: boolean }>`
|
||||
${({ theme, headerPosition, oneLine }) => `
|
||||
${({ theme, headerPosition, oneLine }) => {
|
||||
const useSubtleOptionHover = theme.selectOptionActiveOutline === false;
|
||||
return `
|
||||
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
|
||||
outline: 2px solid ${theme.colorPrimary};
|
||||
outline-offset: -2px;
|
||||
${useSubtleOptionHover ? 'outline: none;' : `outline: 2px solid ${theme.colorPrimary}; outline-offset: -2px;`}
|
||||
}
|
||||
flex: ${headerPosition === 'left' ? 1 : 0};
|
||||
line-height: ${theme.sizeXL}px;
|
||||
@@ -82,7 +83,8 @@ export const StyledSelect = styled(Select, {
|
||||
}
|
||||
`
|
||||
};
|
||||
`}
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
|
||||
export const NoElement = styled.span`
|
||||
|
||||
@@ -29,6 +29,7 @@ import { waitForPost } from '../../helpers/api/intercepts';
|
||||
import { expectStatusOneOf } from '../../helpers/api/assertions';
|
||||
import { getDatabaseByName } from '../../helpers/api/database';
|
||||
import { apiExecuteSql } from '../../helpers/api/sqllab';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
interface ExamplesSetupResult {
|
||||
tableName: string;
|
||||
@@ -116,7 +117,7 @@ async function dropTempTable(
|
||||
// Uses test.describe only because Playwright's serial mode API requires it -
|
||||
// (Deviation from "avoid describe" guideline is necessary for functional reasons)
|
||||
test.describe('create dataset wizard', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.describe.configure({ mode: 'serial', timeout: TIMEOUT.SLOW_TEST });
|
||||
|
||||
test('should create a dataset via wizard', async ({ page, testAssets }) => {
|
||||
const { tableName, dbId, createDatasetPage } = await setupExamplesDataset(
|
||||
|
||||
@@ -33,8 +33,9 @@ import { getLayerConfig } from '../util/controlPanelUtil';
|
||||
export default class CartodiagramPlugin extends ChartPlugin {
|
||||
constructor(opts: CartodiagramPluginConstructorOpts) {
|
||||
const metadata = new ChartMetadata({
|
||||
description:
|
||||
description: t(
|
||||
'Display charts on a map. For using this plugin, users first have to create any other chart that can then be placed on the map.',
|
||||
),
|
||||
name: t('Cartodiagram'),
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
|
||||
@@ -28,8 +28,9 @@ export default class PopKPIPlugin extends ChartPlugin {
|
||||
constructor() {
|
||||
const metadata = new ChartMetadata({
|
||||
category: t('KPI'),
|
||||
description:
|
||||
description: t(
|
||||
'Showcases a metric along with a comparison of value, change, and percent change for a selected time period.',
|
||||
),
|
||||
name: t('Big Number with Time Period Comparison'),
|
||||
tags: [
|
||||
t('Comparison'),
|
||||
|
||||
@@ -282,6 +282,11 @@ export default function transformProps(
|
||||
? formatTime
|
||||
: numberFormatter;
|
||||
|
||||
const lineWidth = 2;
|
||||
// Pad the grid by half the stroke width so the trendline isn't clipped at
|
||||
// the edges of the chart area (the stroke extends beyond the data point).
|
||||
const strokePad = lineWidth / 2;
|
||||
|
||||
const echartOptions: EChartsCoreOption = trendLineData
|
||||
? {
|
||||
series: [
|
||||
@@ -293,6 +298,9 @@ export default function transformProps(
|
||||
symbolSize: 10,
|
||||
showSymbol: false,
|
||||
color: mainColor ?? BRAND_COLOR,
|
||||
lineStyle: {
|
||||
width: lineWidth,
|
||||
},
|
||||
areaStyle: {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
@@ -346,10 +354,10 @@ export default function transformProps(
|
||||
top: TIMESERIES_CONSTANTS.gridOffsetTop,
|
||||
}
|
||||
: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: strokePad,
|
||||
left: strokePad,
|
||||
right: strokePad,
|
||||
top: strokePad,
|
||||
},
|
||||
tooltip: {
|
||||
...getDefaultTooltip(refs),
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { DatasourceType, TimeGranularity, VizType } from '@superset-ui/core';
|
||||
import type { LineSeriesOption } from 'echarts';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import transformProps from '../../src/BigNumber/BigNumberWithTrendline/transformProps';
|
||||
import {
|
||||
@@ -269,11 +270,18 @@ describe('BigNumberWithTrendline', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const series = (
|
||||
transformed.echartOptions?.series as LineSeriesOption[]
|
||||
)?.[0];
|
||||
const lineWidth = series?.lineStyle?.width;
|
||||
expect(lineWidth).toBe(2);
|
||||
|
||||
const expectedPad = (lineWidth as number) / 2;
|
||||
expect(transformed.echartOptions?.grid).toEqual({
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: expectedPad,
|
||||
left: expectedPad,
|
||||
right: expectedPad,
|
||||
top: expectedPad,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1204,8 +1204,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
onClick:
|
||||
emitCrossFilters && !valueRange && !isMetric
|
||||
? () => {
|
||||
const isFilterable = columnsMeta.find(
|
||||
(cm: DataColumnMeta) => cm.key === key,
|
||||
)?.isFilterable;
|
||||
// allow selecting text in a cell
|
||||
if (!getSelectedText()) {
|
||||
if (!getSelectedText() && isFilterable !== false) {
|
||||
toggleFilter(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,9 @@ const processColumns = memoizeOne(function processColumns(
|
||||
const metricsSet = new Set(metrics);
|
||||
const percentMetricsSet = new Set(percentMetrics);
|
||||
const rawPercentMetricsSet = new Set(rawPercentMetrics);
|
||||
const columnsByName = new Map(
|
||||
(props.datasource.columns ?? []).map(col => [col.column_name, col]),
|
||||
);
|
||||
|
||||
const columns: DataColumnMeta[] = (colnames || [])
|
||||
.filter(
|
||||
@@ -244,6 +247,7 @@ const processColumns = memoizeOne(function processColumns(
|
||||
const config = columnConfig[key] || {};
|
||||
// for the purpose of presentation, only numeric values are treated as metrics
|
||||
// because users can also add things like `MAX(str_col)` as a metric.
|
||||
const isFilterable = columnsByName.get(key)?.filterable;
|
||||
const isMetric = metricsSet.has(key) && isNumeric(key, records);
|
||||
const isPercentMetric = percentMetricsSet.has(key);
|
||||
const label =
|
||||
@@ -326,6 +330,7 @@ const processColumns = memoizeOne(function processColumns(
|
||||
isPercentMetric,
|
||||
formatter,
|
||||
config,
|
||||
isFilterable,
|
||||
description,
|
||||
currencyCodeColumn,
|
||||
};
|
||||
|
||||
@@ -2534,3 +2534,33 @@ test('sorts genuinely string columns alphanumerically', () => {
|
||||
const values = Array.from(cells).map(td => td.textContent);
|
||||
expect(values).toEqual(['apple', 'banana', 'cherry']);
|
||||
});
|
||||
|
||||
test('TableChart should NOT emit cross-filter when clicking a cell in a not-filterable column', () => {
|
||||
const setDataMask = jest.fn();
|
||||
const props = transformProps({
|
||||
...testData.basic,
|
||||
datasource: {
|
||||
...testData.basic.datasource,
|
||||
columns: [{ column_name: 'name', filterable: false } as any],
|
||||
},
|
||||
hooks: { setDataMask },
|
||||
emitCrossFilters: true,
|
||||
});
|
||||
render(
|
||||
<ProviderWrapper>
|
||||
<TableChart
|
||||
{...props}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMask}
|
||||
sticky={false}
|
||||
/>
|
||||
</ProviderWrapper>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Michael'));
|
||||
|
||||
const crossFilterCall = setDataMask.mock.calls.find(
|
||||
(call: any[]) => call[0]?.filterState?.filters,
|
||||
);
|
||||
expect(crossFilterCall).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "~9.3.4",
|
||||
"@deck.gl/mapbox": "~9.3.5",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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 { QueryFormData } from '@superset-ui/core';
|
||||
import { getCategories } from './CategoricalDeckGLContainer';
|
||||
import { addColorToFeatures } from './utils/addColor';
|
||||
import { COLOR_SCHEME_TYPES } from './utilities/utils';
|
||||
|
||||
// Record every (label, sliceId) pair the categorical color scale is asked to
|
||||
// resolve, so we can assert the legend and point-color paths key the scale on
|
||||
// the same slice id.
|
||||
const scaleCalls: [string, number | undefined][] = [];
|
||||
jest.mock('@superset-ui/core', () => {
|
||||
const actual = jest.requireActual('@superset-ui/core');
|
||||
return {
|
||||
...actual,
|
||||
CategoricalColorNamespace: {
|
||||
...actual.CategoricalColorNamespace,
|
||||
getScale: () => (value: string, sliceId?: number) => {
|
||||
scaleCalls.push([value, sliceId]);
|
||||
return value === 'A' ? '#ff0000' : '#00ff00';
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
test('legend and point colors resolve from the same slice_id', () => {
|
||||
const fd = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'deck_scatter',
|
||||
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
|
||||
color_scheme: 'supersetColors',
|
||||
dimension: 'category',
|
||||
slice_id: 42,
|
||||
color_picker: { r: 0, g: 0, b: 0, a: 1 },
|
||||
} as unknown as QueryFormData;
|
||||
const data = [{ cat_color: 'A' }, { cat_color: 'B' }];
|
||||
|
||||
const categories = getCategories(fd, data);
|
||||
const features = addColorToFeatures(data, fd);
|
||||
|
||||
// Both the legend path (getCategories) and the point-color path
|
||||
// (addColorToFeatures) key the color scale on the same slice id.
|
||||
expect(scaleCalls.length).toBeGreaterThan(0);
|
||||
scaleCalls.forEach(([, sliceId]) => {
|
||||
expect(sliceId).toBe(42);
|
||||
});
|
||||
|
||||
// The legend swatch for each category matches the resolved point color.
|
||||
expect(categories.A.color).toEqual(features[0].color);
|
||||
expect(categories.B.color).toEqual(features[1].color);
|
||||
expect(features[0].color).not.toEqual(features[1].color);
|
||||
});
|
||||
@@ -47,15 +47,15 @@ import {
|
||||
DeckGLContainerStyledWrapper,
|
||||
} from './DeckGLContainer';
|
||||
import { GetLayerType } from './factory';
|
||||
import { ColorBreakpointType, ColorType, Point } from './types';
|
||||
import { Point } from './types';
|
||||
import { TooltipProps } from './components/Tooltip';
|
||||
import { COLOR_SCHEME_TYPES, ColorSchemeType } from './utilities/utils';
|
||||
import { getColorBreakpointsBuckets } from './utils';
|
||||
import { DEFAULT_DECKGL_COLOR } from './utilities/Shared_DeckGL';
|
||||
import { addColorToFeatures } from './utils/addColor';
|
||||
|
||||
const { getScale } = CategoricalColorNamespace;
|
||||
|
||||
function getCategories(fd: QueryFormData, data: JsonObject[]) {
|
||||
export function getCategories(fd: QueryFormData, data: JsonObject[]) {
|
||||
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
|
||||
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
|
||||
const appliedScheme = fd.color_scheme;
|
||||
@@ -70,7 +70,7 @@ function getCategories(fd: QueryFormData, data: JsonObject[]) {
|
||||
if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) {
|
||||
let color;
|
||||
if (fd.dimension) {
|
||||
color = hexToRGB(colorFn(d.cat_color, fd.sliceId), c.a * 255);
|
||||
color = hexToRGB(colorFn(d.cat_color, fd.slice_id), c.a * 255);
|
||||
} else {
|
||||
color = fixedColor;
|
||||
}
|
||||
@@ -150,80 +150,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
data: JsonObject[],
|
||||
fd: QueryFormData,
|
||||
selectedColorScheme: ColorSchemeType,
|
||||
) => {
|
||||
const appliedScheme = fd.color_scheme;
|
||||
const colorFn = getScale(appliedScheme);
|
||||
let color: ColorType;
|
||||
|
||||
switch (selectedColorScheme) {
|
||||
case COLOR_SCHEME_TYPES.fixed_color: {
|
||||
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
|
||||
const colorArray = [color.r, color.g, color.b, color.a * 255];
|
||||
|
||||
return data.map(d => ({ ...d, color: colorArray }));
|
||||
}
|
||||
case COLOR_SCHEME_TYPES.categorical_palette: {
|
||||
if (!fd.dimension) {
|
||||
const fallbackColor = fd.color_picker || {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 1,
|
||||
};
|
||||
const colorArray = [
|
||||
fallbackColor.r,
|
||||
fallbackColor.g,
|
||||
fallbackColor.b,
|
||||
fallbackColor.a * 255,
|
||||
];
|
||||
return data.map(d => ({ ...d, color: colorArray }));
|
||||
}
|
||||
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
color: hexToRGB(colorFn(d.cat_color, fd.slice_id)),
|
||||
}));
|
||||
}
|
||||
case COLOR_SCHEME_TYPES.color_breakpoints: {
|
||||
const defaultBreakpointColor = fd.default_breakpoint_color
|
||||
? [
|
||||
fd.default_breakpoint_color.r,
|
||||
fd.default_breakpoint_color.g,
|
||||
fd.default_breakpoint_color.b,
|
||||
fd.default_breakpoint_color.a * 255,
|
||||
]
|
||||
: [
|
||||
DEFAULT_DECKGL_COLOR.r,
|
||||
DEFAULT_DECKGL_COLOR.g,
|
||||
DEFAULT_DECKGL_COLOR.b,
|
||||
DEFAULT_DECKGL_COLOR.a * 255,
|
||||
];
|
||||
return data.map(d => {
|
||||
const breakpointForPoint: ColorBreakpointType =
|
||||
fd.color_breakpoints?.find(
|
||||
(breakpoint: ColorBreakpointType) =>
|
||||
d.metric >= breakpoint.minValue &&
|
||||
d.metric <= breakpoint.maxValue,
|
||||
);
|
||||
|
||||
if (breakpointForPoint) {
|
||||
const pointColor = [
|
||||
breakpointForPoint.color.r,
|
||||
breakpointForPoint.color.g,
|
||||
breakpointForPoint.color.b,
|
||||
breakpointForPoint.color.a * 255,
|
||||
];
|
||||
return { ...d, color: pointColor };
|
||||
}
|
||||
|
||||
return { ...d, color: defaultBreakpointColor };
|
||||
});
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
) => addColorToFeatures(data, fd, selectedColorScheme),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { DatasourceType, SupersetClient } from '@superset-ui/core';
|
||||
import DeckMulti from './Multi';
|
||||
|
||||
// Capture the layers handed to the DeckGL container so we can inspect the
|
||||
// per-feature colors that were resolved for each sublayer.
|
||||
interface CapturedDataPoint {
|
||||
color: number[];
|
||||
}
|
||||
interface CapturedLayer {
|
||||
id?: string;
|
||||
props: {
|
||||
data: CapturedDataPoint[];
|
||||
getSourceColor?: (d: Record<string, unknown>) => number[];
|
||||
getTargetColor?: (d: Record<string, unknown>) => number[];
|
||||
};
|
||||
}
|
||||
const mockLayerCapture: { layers: CapturedLayer[] } = { layers: [] };
|
||||
jest.mock('../DeckGLContainer', () => ({
|
||||
DeckGLContainerStyledWrapper: ({ layers }: { layers?: CapturedLayer[] }) => {
|
||||
mockLayerCapture.layers = layers || [];
|
||||
return <div data-test="deckgl-container">DeckGL Container Mock</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
SupersetClient: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockStore = configureStore({
|
||||
reducer: {
|
||||
dataMask: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) =>
|
||||
render(
|
||||
<Provider store={mockStore}>
|
||||
<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const SCATTER_SLICE_ID = 1;
|
||||
|
||||
const props = {
|
||||
formData: {
|
||||
datasource: '1__table',
|
||||
viz_type: 'deck_multi',
|
||||
deck_slices: [SCATTER_SLICE_ID],
|
||||
autozoom: false,
|
||||
map_style: 'mapbox://styles/mapbox/light-v9',
|
||||
},
|
||||
payload: {
|
||||
data: {
|
||||
slices: [
|
||||
{
|
||||
slice_id: SCATTER_SLICE_ID,
|
||||
form_data: {
|
||||
viz_type: 'deck_scatter',
|
||||
datasource: '1__table',
|
||||
slice_id: SCATTER_SLICE_ID,
|
||||
// categorical color configuration coming from the saved scatter chart
|
||||
color_scheme_type: 'categorical_palette',
|
||||
color_scheme: 'supersetColors',
|
||||
dimension: 'category',
|
||||
},
|
||||
},
|
||||
],
|
||||
features: {
|
||||
deck_scatter: [],
|
||||
},
|
||||
mapboxApiKey: 'test-key',
|
||||
},
|
||||
},
|
||||
setControlValue: jest.fn(),
|
||||
viewport: { longitude: 0, latitude: 0, zoom: 1 },
|
||||
onAddFilter: jest.fn(),
|
||||
height: 600,
|
||||
width: 800,
|
||||
datasource: {
|
||||
id: 1,
|
||||
type: DatasourceType.Table,
|
||||
name: 'test_datasource',
|
||||
columns: [],
|
||||
metrics: [],
|
||||
columnFormats: {},
|
||||
currencyFormats: {},
|
||||
verboseMap: {},
|
||||
},
|
||||
onSelect: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockLayerCapture.layers = [];
|
||||
// The scatter sublayer query returns features tagged with a category column.
|
||||
(SupersetClient.get as jest.Mock).mockResolvedValue({
|
||||
json: {
|
||||
data: {
|
||||
features: [
|
||||
{ position: [0, 0], radius: 1, cat_color: 'A' },
|
||||
{ position: [1, 1], radius: 1, cat_color: 'B' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const expectDistinctCategoricalColors = async () => {
|
||||
await waitFor(() => {
|
||||
expect(mockLayerCapture.layers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const scatterLayer = mockLayerCapture.layers.find((layer: CapturedLayer) =>
|
||||
layer?.id?.startsWith('scatter-layer-'),
|
||||
);
|
||||
expect(scatterLayer).toBeDefined();
|
||||
|
||||
const { data } = (scatterLayer as CapturedLayer).props;
|
||||
expect(data).toHaveLength(2);
|
||||
|
||||
// Both points must carry a resolved RGBA color...
|
||||
data.forEach((d: CapturedDataPoint) => {
|
||||
expect(Array.isArray(d.color)).toBe(true);
|
||||
expect(d.color).toHaveLength(4);
|
||||
});
|
||||
|
||||
// ...and the two distinct categories must NOT share the same color. Before
|
||||
// the fix, categorical colors were dropped in the Multiple Layers chart and
|
||||
// every point fell back to the same default color.
|
||||
expect(data[0].color).not.toEqual(data[1].color);
|
||||
};
|
||||
|
||||
test('applies categorical scatterplot colors to sublayers in the multi chart', async () => {
|
||||
renderWithProviders(<DeckMulti {...props} />);
|
||||
|
||||
await expectDistinctCategoricalColors();
|
||||
});
|
||||
|
||||
test('applies categorical colors to scatter subslices saved before the color_scheme_type control existed', async () => {
|
||||
// Charts saved before the color_scheme_type control existed lack the key in
|
||||
// stored params; the scatter default (categorical_palette) must be resolved
|
||||
// so they keep per-category colors.
|
||||
const legacyProps = {
|
||||
...props,
|
||||
payload: {
|
||||
...props.payload,
|
||||
data: {
|
||||
...props.payload.data,
|
||||
slices: [
|
||||
{
|
||||
slice_id: SCATTER_SLICE_ID,
|
||||
form_data: {
|
||||
viz_type: 'deck_scatter',
|
||||
datasource: '1__table',
|
||||
slice_id: SCATTER_SLICE_ID,
|
||||
color_scheme: 'supersetColors',
|
||||
dimension: 'category',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(<DeckMulti {...legacyProps} />);
|
||||
|
||||
await expectDistinctCategoricalColors();
|
||||
});
|
||||
|
||||
test('keeps fixed source and target colors for arc subslices saved before the color_scheme_type control existed', async () => {
|
||||
// Legacy arcs default to fixed_color, where the layer reads the source and
|
||||
// target pickers directly; resolving the default must not stamp a single
|
||||
// per-feature color over the target color.
|
||||
const ARC_SLICE_ID = 2;
|
||||
const arcProps = {
|
||||
...props,
|
||||
formData: { ...props.formData, deck_slices: [ARC_SLICE_ID] },
|
||||
payload: {
|
||||
...props.payload,
|
||||
data: {
|
||||
...props.payload.data,
|
||||
slices: [
|
||||
{
|
||||
slice_id: ARC_SLICE_ID,
|
||||
form_data: {
|
||||
viz_type: 'deck_arc',
|
||||
datasource: '1__table',
|
||||
slice_id: ARC_SLICE_ID,
|
||||
color_picker: { r: 10, g: 20, b: 30, a: 1 },
|
||||
target_color_picker: { r: 40, g: 50, b: 60, a: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
features: { deck_arc: [] },
|
||||
},
|
||||
},
|
||||
};
|
||||
(SupersetClient.get as jest.Mock).mockResolvedValue({
|
||||
json: {
|
||||
data: {
|
||||
features: [{ sourcePosition: [0, 0], targetPosition: [1, 1] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<DeckMulti {...arcProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLayerCapture.layers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const arcLayer = mockLayerCapture.layers.find(
|
||||
(layer: CapturedLayer) => layer?.id === `path-layer-${ARC_SLICE_ID}`,
|
||||
);
|
||||
expect(arcLayer).toBeDefined();
|
||||
|
||||
expect(arcLayer?.props.getSourceColor?.({})).toEqual([10, 20, 30, 255]);
|
||||
expect(arcLayer?.props.getTargetColor?.({})).toEqual([40, 50, 60, 255]);
|
||||
});
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
DeckGLContainerStyledWrapper,
|
||||
} from '../DeckGLContainer';
|
||||
import { getExploreLongUrl } from '../utils/explore';
|
||||
import { addColorToFeatures } from '../utils/addColor';
|
||||
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../utilities/utils';
|
||||
import layerGenerators from '../layers';
|
||||
import fitViewport, { Viewport } from '../utils/fitViewport';
|
||||
import { getMapboxApiKey } from '../utils/mapbox';
|
||||
@@ -98,6 +100,16 @@ const MultiWrapper = styled.div<{ height: number; width: number }>`
|
||||
width: ${({ width }) => width}px;
|
||||
`;
|
||||
|
||||
// Default color_scheme_type per color-aware layer type, matching each control
|
||||
// panel. Sub-slices arrive as raw saved form data without control-default
|
||||
// hydration, so charts saved before this control existed need the default
|
||||
// resolved here to keep their configured colors.
|
||||
const COLOR_AWARE_LAYER_DEFAULTS: Record<string, ColorSchemeType> = {
|
||||
deck_scatter: COLOR_SCHEME_TYPES.categorical_palette,
|
||||
deck_path: COLOR_SCHEME_TYPES.fixed_color,
|
||||
deck_arc: COLOR_SCHEME_TYPES.fixed_color,
|
||||
};
|
||||
|
||||
const selectDataMask = createSelector(
|
||||
(state: { dataMask?: DataMaskState }) => state.dataMask,
|
||||
dataMask => dataMask || {},
|
||||
@@ -225,15 +237,43 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
);
|
||||
|
||||
const createLayerFromData = useCallback(
|
||||
(subslice: JsonObject, json: JsonObject): Layer =>
|
||||
// @ts-expect-error TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators.
|
||||
layerGenerators[subslice.form_data.viz_type]({
|
||||
formData: subslice.form_data,
|
||||
payload: json,
|
||||
setTooltip,
|
||||
datasource: props.datasource,
|
||||
onSelect: props.onSelect,
|
||||
}),
|
||||
(subslice: JsonObject, json: JsonObject): Layer => {
|
||||
const { form_data: subsliceFormData } = subslice;
|
||||
const defaultColorSchemeType =
|
||||
COLOR_AWARE_LAYER_DEFAULTS[subsliceFormData.viz_type];
|
||||
let layerFormData = subsliceFormData;
|
||||
let payload = json;
|
||||
|
||||
// Resolve per-feature colors as CategoricalDeckGLContainer does when
|
||||
// the layer renders standalone.
|
||||
if (defaultColorSchemeType) {
|
||||
layerFormData = {
|
||||
...subsliceFormData,
|
||||
color_scheme_type:
|
||||
subsliceFormData.color_scheme_type ?? defaultColorSchemeType,
|
||||
};
|
||||
if (Array.isArray(json?.data?.features)) {
|
||||
payload = {
|
||||
...json,
|
||||
data: {
|
||||
...json.data,
|
||||
features: addColorToFeatures(json.data.features, layerFormData),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
// @ts-expect-error TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators.
|
||||
layerGenerators[layerFormData.viz_type]({
|
||||
formData: layerFormData,
|
||||
payload,
|
||||
setTooltip,
|
||||
datasource: props.datasource,
|
||||
onSelect: props.onSelect,
|
||||
})
|
||||
);
|
||||
},
|
||||
[props.onSelect, props.datasource, setTooltip],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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 { QueryFormData } from '@superset-ui/core';
|
||||
import { addColorToFeatures } from './addColor';
|
||||
import { COLOR_SCHEME_TYPES } from '../utilities/utils';
|
||||
|
||||
const baseFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'deck_scatter',
|
||||
} as unknown as QueryFormData;
|
||||
|
||||
test('assigns distinct colors per category for a categorical palette', () => {
|
||||
const features = [{ cat_color: 'A' }, { cat_color: 'B' }, { cat_color: 'A' }];
|
||||
const result = addColorToFeatures(features, {
|
||||
...baseFormData,
|
||||
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
|
||||
color_scheme: 'supersetColors',
|
||||
dimension: 'category',
|
||||
slice_id: 1,
|
||||
} as unknown as QueryFormData);
|
||||
|
||||
// Each feature gets a resolved RGBA color
|
||||
result.forEach(d => {
|
||||
expect(Array.isArray(d.color)).toBe(true);
|
||||
expect(d.color).toHaveLength(4);
|
||||
});
|
||||
// Same category resolves to the same color, different categories differ
|
||||
expect(result[0].color).toEqual(result[2].color);
|
||||
expect(result[0].color).not.toEqual(result[1].color);
|
||||
});
|
||||
|
||||
test('falls back to the fixed color picker when no dimension is set', () => {
|
||||
const features = [{ cat_color: 'A' }, { cat_color: 'B' }];
|
||||
const result = addColorToFeatures(features, {
|
||||
...baseFormData,
|
||||
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
|
||||
color_picker: { r: 10, g: 20, b: 30, a: 1 },
|
||||
} as unknown as QueryFormData);
|
||||
|
||||
result.forEach(d => {
|
||||
expect(d.color).toEqual([10, 20, 30, 255]);
|
||||
});
|
||||
});
|
||||
|
||||
test('applies the fixed color scheme to every feature', () => {
|
||||
const features = [{ cat_color: 'A' }, { cat_color: 'B' }];
|
||||
const result = addColorToFeatures(features, {
|
||||
...baseFormData,
|
||||
color_scheme_type: COLOR_SCHEME_TYPES.fixed_color,
|
||||
color_picker: { r: 1, g: 2, b: 3, a: 0.5 },
|
||||
} as unknown as QueryFormData);
|
||||
|
||||
result.forEach(d => {
|
||||
expect(d.color).toEqual([1, 2, 3, 127.5]);
|
||||
});
|
||||
});
|
||||
|
||||
test('assigns breakpoint colors by metric and falls back to the default', () => {
|
||||
const features = [{ metric: 5 }, { metric: 50 }, { metric: 500 }];
|
||||
const result = addColorToFeatures(features, {
|
||||
...baseFormData,
|
||||
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
|
||||
color_breakpoints: [
|
||||
{ minValue: 0, maxValue: 10, color: { r: 1, g: 2, b: 3, a: 1 } },
|
||||
{ minValue: 11, maxValue: 100, color: { r: 4, g: 5, b: 6, a: 0.5 } },
|
||||
],
|
||||
default_breakpoint_color: { r: 7, g: 8, b: 9, a: 1 },
|
||||
} as unknown as QueryFormData);
|
||||
|
||||
// Metric inside the first breakpoint range
|
||||
expect(result[0].color).toEqual([1, 2, 3, 255]);
|
||||
// Metric inside the second breakpoint range (alpha scaled to 0-255)
|
||||
expect(result[1].color).toEqual([4, 5, 6, 127.5]);
|
||||
// Metric outside every range falls back to the default breakpoint color
|
||||
expect(result[2].color).toEqual([7, 8, 9, 255]);
|
||||
});
|
||||
|
||||
test('returns features unchanged for an unrecognized color scheme', () => {
|
||||
const features = [{ cat_color: 'A' }];
|
||||
const result = addColorToFeatures(features, {
|
||||
...baseFormData,
|
||||
color_scheme_type: 'something_else',
|
||||
} as unknown as QueryFormData);
|
||||
|
||||
expect(result).toEqual(features);
|
||||
expect(result[0].color).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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 {
|
||||
CategoricalColorNamespace,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { hexToRGB } from './colors';
|
||||
import { ColorBreakpointType } from '../types';
|
||||
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../utilities/utils';
|
||||
import { DEFAULT_DECKGL_COLOR } from '../utilities/Shared_DeckGL';
|
||||
|
||||
const { getScale } = CategoricalColorNamespace;
|
||||
|
||||
/**
|
||||
* Resolve the per-feature color for a deck.gl layer based on the form data's
|
||||
* color scheme configuration. This mirrors the categorical/fixed/breakpoint
|
||||
* color logic that `CategoricalDeckGLContainer` applies when a layer is
|
||||
* rendered on its own, so that it can be reused when layers are composed
|
||||
* inside the deck.gl Multiple Layers chart.
|
||||
*
|
||||
* Features whose color scheme is not recognized are returned unchanged so the
|
||||
* layer's own fallback color logic can take over.
|
||||
*/
|
||||
export function addColorToFeatures(
|
||||
data: JsonObject[],
|
||||
fd: QueryFormData,
|
||||
selectedColorScheme: ColorSchemeType = fd.color_scheme_type,
|
||||
): JsonObject[] {
|
||||
const appliedScheme = fd.color_scheme;
|
||||
const colorFn = getScale(appliedScheme);
|
||||
|
||||
switch (selectedColorScheme) {
|
||||
case COLOR_SCHEME_TYPES.fixed_color: {
|
||||
const color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
|
||||
const colorArray = [color.r, color.g, color.b, color.a * 255];
|
||||
|
||||
return data.map(d => ({ ...d, color: colorArray }));
|
||||
}
|
||||
case COLOR_SCHEME_TYPES.categorical_palette: {
|
||||
if (!fd.dimension) {
|
||||
const fallbackColor = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
|
||||
const colorArray = [
|
||||
fallbackColor.r,
|
||||
fallbackColor.g,
|
||||
fallbackColor.b,
|
||||
fallbackColor.a * 255,
|
||||
];
|
||||
return data.map(d => ({ ...d, color: colorArray }));
|
||||
}
|
||||
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
color: hexToRGB(colorFn(d.cat_color, fd.slice_id)),
|
||||
}));
|
||||
}
|
||||
case COLOR_SCHEME_TYPES.color_breakpoints: {
|
||||
const defaultBreakpointColor = fd.default_breakpoint_color
|
||||
? [
|
||||
fd.default_breakpoint_color.r,
|
||||
fd.default_breakpoint_color.g,
|
||||
fd.default_breakpoint_color.b,
|
||||
fd.default_breakpoint_color.a * 255,
|
||||
]
|
||||
: [
|
||||
DEFAULT_DECKGL_COLOR.r,
|
||||
DEFAULT_DECKGL_COLOR.g,
|
||||
DEFAULT_DECKGL_COLOR.b,
|
||||
DEFAULT_DECKGL_COLOR.a * 255,
|
||||
];
|
||||
return data.map(d => {
|
||||
const breakpointForPoint: ColorBreakpointType =
|
||||
fd.color_breakpoints?.find(
|
||||
(breakpoint: ColorBreakpointType) =>
|
||||
d.metric >= breakpoint.minValue &&
|
||||
d.metric <= breakpoint.maxValue,
|
||||
);
|
||||
|
||||
if (breakpointForPoint) {
|
||||
const pointColor = [
|
||||
breakpointForPoint.color.r,
|
||||
breakpointForPoint.color.g,
|
||||
breakpointForPoint.color.b,
|
||||
breakpointForPoint.color.a * 255,
|
||||
];
|
||||
return { ...d, color: pointColor };
|
||||
}
|
||||
|
||||
return { ...d, color: defaultBreakpointColor };
|
||||
});
|
||||
}
|
||||
default:
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const CardContainer = styled.div<{ showThumbnails?: boolean }>`
|
||||
display: grid;
|
||||
justify-content: start;
|
||||
grid-gap: ${theme.sizeUnit * 12}px ${theme.sizeUnit * 4}px;
|
||||
grid-template-columns: repeat(auto-fit, 300px);
|
||||
grid-template-columns: repeat(auto-fit, ${theme.sizeUnit * 75}px);
|
||||
margin-top: ${theme.sizeUnit * -6}px;
|
||||
padding: ${
|
||||
showThumbnails
|
||||
|
||||
@@ -355,7 +355,7 @@ export const hydrateDashboard =
|
||||
'Superset',
|
||||
roles,
|
||||
),
|
||||
superset_can_csv: findPermission('can_csv', 'Superset', roles),
|
||||
superset_can_download: findPermission('can_csv', 'Superset', roles),
|
||||
common: {
|
||||
// legacy, please use state.common instead
|
||||
conf: common?.conf,
|
||||
|
||||
@@ -316,7 +316,7 @@ const StyledDashboardContent = styled.div<{
|
||||
.dashboard-component-chart-holder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: ${theme.colorBgContainer};
|
||||
background-color: ${theme.dashboardTileBg ?? theme.colorBgContainer};
|
||||
position: relative;
|
||||
padding: ${theme.sizeUnit * 4}px;
|
||||
box-sizing: border-box;
|
||||
@@ -336,8 +336,11 @@ const StyledDashboardContent = styled.div<{
|
||||
}
|
||||
|
||||
&.fade-out {
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
box-shadow: 0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)};
|
||||
border: ${theme.dashboardTileBorder ?? 'none'};
|
||||
border-radius: ${theme.dashboardTileBorderRadius ??
|
||||
theme.borderRadius}px;
|
||||
box-shadow: ${theme.dashboardTileBoxShadow ??
|
||||
`0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`};
|
||||
}
|
||||
|
||||
& .missing-chart-container {
|
||||
|
||||
@@ -35,7 +35,7 @@ jest.mock('src/dashboard/components/SliceHeaderControls', () => ({
|
||||
data-cached-dttm={props.cachedDttm}
|
||||
data-updated-dttm={props.updatedDttm}
|
||||
data-superset-can-explore={props.supersetCanExplore}
|
||||
data-superset-can-csv={props.supersetCanCSV}
|
||||
data-superset-can-download={props.supersetCanDownload}
|
||||
data-component-id={props.componentId}
|
||||
data-dashboard-id={props.dashboardId}
|
||||
data-is-full-size={props.isFullSize}
|
||||
@@ -144,7 +144,7 @@ const createProps = (overrides: any = {}) => ({
|
||||
isExpanded: false,
|
||||
sliceName: 'Vaccine Candidates per Phase',
|
||||
supersetCanExplore: true,
|
||||
supersetCanCSV: true,
|
||||
supersetCanDownload: true,
|
||||
slice: {
|
||||
slice_id: MOCKED_CHART_ID,
|
||||
slice_url: `/explore/?form_data=%7B%22slice_id%22%3A%20${MOCKED_CHART_ID}%7D`,
|
||||
@@ -222,7 +222,7 @@ test('Should render - default props', () => {
|
||||
delete props.isExpanded;
|
||||
delete props.sliceName;
|
||||
delete props.supersetCanExplore;
|
||||
delete props.supersetCanCSV;
|
||||
delete props.supersetCanDownload;
|
||||
|
||||
render(<SliceHeader {...props} />, {
|
||||
useRedux: true,
|
||||
@@ -250,7 +250,7 @@ test('Should render default props and "call" actions', () => {
|
||||
delete props.isExpanded;
|
||||
delete props.sliceName;
|
||||
delete props.supersetCanExplore;
|
||||
delete props.supersetCanCSV;
|
||||
delete props.supersetCanDownload;
|
||||
|
||||
render(<SliceHeader {...props} />, {
|
||||
useRedux: true,
|
||||
@@ -459,7 +459,7 @@ test('Correct props to "SliceHeaderControls"', () => {
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByTestId('SliceHeaderControls')).toHaveAttribute(
|
||||
'data-superset-can-csv',
|
||||
'data-superset-can-download',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByTestId('SliceHeaderControls')).toHaveAttribute(
|
||||
|
||||
@@ -157,7 +157,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
sliceName = '',
|
||||
supersetCanExplore = false,
|
||||
supersetCanShare = false,
|
||||
supersetCanCSV = false,
|
||||
supersetCanDownload = false,
|
||||
exportPivotCSV,
|
||||
exportFullCSV,
|
||||
exportFullXLSX,
|
||||
@@ -367,7 +367,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
exportFullXLSX={exportFullXLSX}
|
||||
supersetCanExplore={supersetCanExplore}
|
||||
supersetCanShare={supersetCanShare}
|
||||
supersetCanCSV={supersetCanCSV}
|
||||
supersetCanDownload={supersetCanDownload}
|
||||
componentId={componentId}
|
||||
dashboardId={dashboardId}
|
||||
addSuccessToast={addSuccessToast}
|
||||
|
||||
@@ -98,7 +98,7 @@ const buildProps = (): SliceHeaderControlsProps =>
|
||||
cachedDttm: [''],
|
||||
updatedDttm: 0,
|
||||
supersetCanExplore: true,
|
||||
supersetCanCSV: true,
|
||||
supersetCanDownload: true,
|
||||
componentId: 'CHART-subdir',
|
||||
dashboardId: 26,
|
||||
isFullSize: false,
|
||||
|
||||
@@ -87,7 +87,7 @@ const createProps = (viz_type = VizType.Sunburst) =>
|
||||
cachedDttm: [''],
|
||||
updatedDttm: 1617213803803,
|
||||
supersetCanExplore: true,
|
||||
supersetCanCSV: true,
|
||||
supersetCanDownload: true,
|
||||
componentId: 'CHART-fYo7IyvKZQ',
|
||||
dashboardId: 26,
|
||||
isFullSize: false,
|
||||
|
||||
@@ -141,7 +141,7 @@ export interface SliceHeaderControlsProps {
|
||||
|
||||
supersetCanExplore?: boolean;
|
||||
supersetCanShare?: boolean;
|
||||
supersetCanCSV?: boolean;
|
||||
supersetCanDownload?: boolean;
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
}
|
||||
@@ -519,7 +519,7 @@ const SliceHeaderControls = (
|
||||
dataSize={20}
|
||||
isRequest
|
||||
isVisible
|
||||
canDownload={!!props.supersetCanCSV}
|
||||
canDownload={!!props.supersetCanDownload}
|
||||
columnDisplayNames={datasetWithVerboseMap?.verbose_map}
|
||||
/>
|
||||
}
|
||||
@@ -562,7 +562,7 @@ const SliceHeaderControls = (
|
||||
newMenuItems.push(shareMenuItems);
|
||||
}
|
||||
|
||||
if (props.supersetCanCSV) {
|
||||
if (props.supersetCanDownload) {
|
||||
newMenuItems.push({
|
||||
type: 'submenu',
|
||||
key: MenuKeys.Download,
|
||||
@@ -593,7 +593,7 @@ const SliceHeaderControls = (
|
||||
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||
},
|
||||
...(isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
|
||||
props.supersetCanCSV &&
|
||||
props.supersetCanDownload &&
|
||||
isTable
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface SliceHeaderControlsProps {
|
||||
|
||||
supersetCanExplore?: boolean;
|
||||
supersetCanShare?: boolean;
|
||||
supersetCanCSV?: boolean;
|
||||
supersetCanDownload?: boolean;
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ const defaultState = {
|
||||
id: props.dashboardId,
|
||||
superset_can_explore: false,
|
||||
superset_can_share: false,
|
||||
superset_can_csv: false,
|
||||
superset_can_download: false,
|
||||
common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 0, SQL_MAX_ROW: 666 } },
|
||||
},
|
||||
dashboardLayout: {
|
||||
@@ -181,7 +181,10 @@ test('should call exportChart when exportCSV is clicked', async () => {
|
||||
const { findByText, getByRole } = setup(
|
||||
{},
|
||||
{
|
||||
dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
|
||||
dashboardInfo: {
|
||||
...defaultState.dashboardInfo,
|
||||
superset_can_download: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
fireEvent.click(getByRole('button', { name: 'More Options' }));
|
||||
@@ -211,7 +214,10 @@ test('should call exportChart with row_limit props.maxRows when exportFullCSV is
|
||||
const { findByText, getByRole } = setup(
|
||||
{},
|
||||
{
|
||||
dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
|
||||
dashboardInfo: {
|
||||
...defaultState.dashboardInfo,
|
||||
superset_can_download: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
fireEvent.click(getByRole('button', { name: 'More Options' }));
|
||||
@@ -239,7 +245,10 @@ test('should call exportChart when exportXLSX is clicked', async () => {
|
||||
const { findByText, getByRole } = setup(
|
||||
{},
|
||||
{
|
||||
dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
|
||||
dashboardInfo: {
|
||||
...defaultState.dashboardInfo,
|
||||
superset_can_download: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
fireEvent.click(getByRole('button', { name: 'More Options' }));
|
||||
@@ -266,7 +275,10 @@ test('should call exportChart with row_limit props.maxRows when exportFullXLSX i
|
||||
const { findByText, getByRole } = setup(
|
||||
{},
|
||||
{
|
||||
dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
|
||||
dashboardInfo: {
|
||||
...defaultState.dashboardInfo,
|
||||
superset_can_download: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
fireEvent.click(getByRole('button', { name: 'More Options' }));
|
||||
|
||||
@@ -218,9 +218,9 @@ const Chart = (props: ChartProps) => {
|
||||
(state: RootState) =>
|
||||
!!(state.dashboardInfo as JsonObject).superset_can_share,
|
||||
);
|
||||
const supersetCanCSV = useSelector(
|
||||
const supersetCanDownload = useSelector(
|
||||
(state: RootState) =>
|
||||
!!(state.dashboardInfo as JsonObject).superset_can_csv,
|
||||
!!(state.dashboardInfo as JsonObject).superset_can_download,
|
||||
);
|
||||
const timeout: number = useSelector(
|
||||
(state: RootState) =>
|
||||
@@ -710,7 +710,7 @@ const Chart = (props: ChartProps) => {
|
||||
sliceName={props.sliceName}
|
||||
supersetCanExplore={supersetCanExplore}
|
||||
supersetCanShare={supersetCanShare}
|
||||
supersetCanCSV={supersetCanCSV}
|
||||
supersetCanDownload={supersetCanDownload}
|
||||
componentId={props.componentId}
|
||||
dashboardId={props.dashboardId}
|
||||
filters={getActiveFilters() || EMPTY_OBJECT}
|
||||
|
||||
@@ -82,7 +82,9 @@ test('drag and drop', () => {
|
||||
test('remove filter', async () => {
|
||||
defaultRender();
|
||||
// First trash icon
|
||||
const removeFilterIcon = document.querySelector("[alt='Remove filter']")!;
|
||||
const removeFilterIcon = document.querySelector(
|
||||
"[aria-label='Remove filter']",
|
||||
)!;
|
||||
userEvent.click(removeFilterIcon);
|
||||
expect(defaultProps.onRemove).toHaveBeenCalledWith('NATIVE_FILTER-1');
|
||||
});
|
||||
|
||||
@@ -211,7 +211,7 @@ const FilterTitleContainer = forwardRef<HTMLDivElement, Props>(
|
||||
event.stopPropagation();
|
||||
onRemove(id);
|
||||
}}
|
||||
alt={t('Remove filter')}
|
||||
aria-label={t('Remove filter')}
|
||||
data-test="filter-remove-button"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -181,6 +181,7 @@ const NAME_REQUIRED_REGEX = /^name is required$/i;
|
||||
const COLUMN_REQUIRED_REGEX = /^column is required$/i;
|
||||
const PRE_FILTER_REQUIRED_REGEX = /^pre-filter is required$/i;
|
||||
const DEFAULT_VALUE_INVALID_REGEX = /choose.*valid value/i;
|
||||
const REMOVE_FILTER_BUTTON_REGEX = /Remove filter/i;
|
||||
|
||||
const props: FiltersConfigModalProps = {
|
||||
isOpen: true,
|
||||
@@ -974,13 +975,15 @@ test('restores a deleted filter via the "Restore filter" button', async () => {
|
||||
|
||||
const filterContainer = screen.getByTestId('filter-title-container');
|
||||
const firstTab = within(filterContainer).getAllByRole('tab')[0];
|
||||
fireEvent.click(within(firstTab).getByRole('button', { name: /delete/i }));
|
||||
fireEvent.click(
|
||||
within(firstTab).getByRole('button', { name: REMOVE_FILTER_BUTTON_REGEX }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/you have removed this filter/i),
|
||||
).toBeInTheDocument();
|
||||
const restoreButton = screen.getByTestId('restore-filter-button');
|
||||
await userEvent.click(restoreButton);
|
||||
userEvent.click(restoreButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -1009,11 +1012,13 @@ test('undoes a filter deletion via the sidebar "Undo?" link', async () => {
|
||||
|
||||
const filterContainer = screen.getByTestId('filter-title-container');
|
||||
const firstTab = within(filterContainer).getAllByRole('tab')[0];
|
||||
fireEvent.click(within(firstTab).getByRole('button', { name: /delete/i }));
|
||||
fireEvent.click(
|
||||
within(firstTab).getByRole('button', { name: REMOVE_FILTER_BUTTON_REGEX }),
|
||||
);
|
||||
|
||||
const undoButton = await screen.findByTestId('undo-button');
|
||||
expect(undoButton).toHaveTextContent(/undo\?/i);
|
||||
await userEvent.click(undoButton);
|
||||
userEvent.click(undoButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
|
||||
@@ -179,7 +179,7 @@ const ItemTitleContainer = forwardRef<HTMLDivElement, Props>(
|
||||
event.stopPropagation();
|
||||
onRemove(id);
|
||||
}}
|
||||
alt={deleteAltText}
|
||||
aria-label={deleteAltText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
createStore,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
useDashboardCharts,
|
||||
useDashboardDatasets,
|
||||
} from 'src/hooks/apiResources';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { SupersetApiError, SupersetClient } from '@superset-ui/core';
|
||||
import CrudThemeProvider from 'src/components/CrudThemeProvider';
|
||||
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
|
||||
import {
|
||||
@@ -559,6 +560,48 @@ test('does not overwrite filterState when modern native_filters URL format is us
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('renders a not-found state instead of throwing when the dashboard 404s', async () => {
|
||||
mockUseDashboard.mockReturnValue({
|
||||
result: null,
|
||||
error: new SupersetApiError({ status: 404, message: 'Not found' }),
|
||||
});
|
||||
mockUseDashboardCharts.mockReturnValue({
|
||||
result: null,
|
||||
error: new SupersetApiError({ status: 404, message: 'Not found' }),
|
||||
});
|
||||
mockUseDashboardDatasets.mockReturnValue({
|
||||
result: null,
|
||||
error: new SupersetApiError({ status: 404, message: 'Not found' }),
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="404" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: {},
|
||||
dashboardState: { sliceIds: [] },
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('This dashboard does not exist'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('dashboard-builder')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'See all dashboards' }),
|
||||
);
|
||||
expect(window.location.pathname).toBe('/dashboard/list/');
|
||||
});
|
||||
|
||||
test('clears undo history after hydrating the dashboard', async () => {
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useTheme } from '@apache-superset/core/theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { Loading } from '@superset-ui/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import {
|
||||
useDashboard,
|
||||
useDashboardCharts,
|
||||
@@ -67,7 +67,8 @@ import SyncDashboardState, {
|
||||
getDashboardContextLocalStorage,
|
||||
} from '../components/SyncDashboardState';
|
||||
import { AutoRefreshProvider } from '../contexts/AutoRefreshContext';
|
||||
import { Filter, PartialFilters } from '@superset-ui/core';
|
||||
import { Filter, PartialFilters, SupersetApiError } from '@superset-ui/core';
|
||||
import { RoutePaths } from 'src/views/routePaths';
|
||||
import {
|
||||
parseRisonFilters,
|
||||
risonFiltersToExtraFormDataFilters,
|
||||
@@ -151,6 +152,9 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
const isDashboardHydrated = useRef(false);
|
||||
|
||||
const error = dashboardApiError || chartsApiError;
|
||||
// Only 404 gets a graceful not-found state; a 403 (access denied) still
|
||||
// surfaces through the error boundary.
|
||||
const isNotFoundError = (error as SupersetApiError | null)?.status === 404;
|
||||
const readyToRender = Boolean(dashboard && charts);
|
||||
const { dashboard_title, id = 0 } = dashboard || {};
|
||||
|
||||
@@ -365,18 +369,21 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (datasetsApiError) {
|
||||
addDangerToast(
|
||||
t('Error loading chart datasources. Filters may not work correctly.'),
|
||||
);
|
||||
// A missing dashboard also 404s its datasets; the not-found state covers it.
|
||||
if (!isNotFoundError) {
|
||||
addDangerToast(
|
||||
t('Error loading chart datasources. Filters may not work correctly.'),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
dispatch(setDatasources(datasets));
|
||||
}
|
||||
}, [addDangerToast, datasets, datasetsApiError, dispatch]);
|
||||
}, [addDangerToast, datasets, datasetsApiError, dispatch, isNotFoundError]);
|
||||
|
||||
const relevantDataMask = useSelector(selectRelevantDatamask);
|
||||
const activeFilters = useSelector(selectActiveFilters);
|
||||
|
||||
if (error) throw error; // caught in error boundary
|
||||
if (error && !isNotFoundError) throw error; // caught in error boundary
|
||||
|
||||
const globalStyles = useMemo(
|
||||
() => [
|
||||
@@ -389,9 +396,25 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
[theme],
|
||||
);
|
||||
|
||||
if (error) throw error; // caught in error boundary
|
||||
if (error && !isNotFoundError) throw error; // caught in error boundary
|
||||
|
||||
const DashboardBuilderComponent = useMemo(() => <DashboardBuilder />, []);
|
||||
|
||||
if (isNotFoundError) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
image="empty-dashboard.svg"
|
||||
title={t('This dashboard does not exist')}
|
||||
description={t(
|
||||
'The dashboard you are looking for may have been deleted or moved.',
|
||||
)}
|
||||
buttonText={t('See all dashboards')}
|
||||
buttonAction={() => history.push(RoutePaths.DASHBOARD_LIST)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Global styles={globalStyles} />
|
||||
|
||||
37
superset-frontend/src/explore/components/DataTableControl/FilterInput.test.tsx
Normal file → Executable file
37
superset-frontend/src/explore/components/DataTableControl/FilterInput.test.tsx
Normal file → Executable file
@@ -34,3 +34,40 @@ test('Render a FilterInput', async () => {
|
||||
|
||||
expect(onChangeHandler).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
test('FilterInput auto-focuses when a non-editable element (e.g. a tab) has focus', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const button = document.createElement('button');
|
||||
document.body.appendChild(button);
|
||||
try {
|
||||
button.focus();
|
||||
expect(document.activeElement).toBe(button);
|
||||
|
||||
render(<FilterInput onChangeHandler={onChangeHandler} shouldFocus />);
|
||||
const filterInput = screen.getByPlaceholderText('Search');
|
||||
|
||||
// Auto-focus should fire — a button is not an editable element
|
||||
expect(document.activeElement).toBe(filterInput);
|
||||
} finally {
|
||||
document.body.removeChild(button);
|
||||
}
|
||||
});
|
||||
|
||||
test('FilterInput does not steal focus when another input already has focus', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const otherInput = document.createElement('input');
|
||||
document.body.appendChild(otherInput);
|
||||
try {
|
||||
otherInput.focus();
|
||||
expect(document.activeElement).toBe(otherInput);
|
||||
|
||||
render(<FilterInput onChangeHandler={onChangeHandler} shouldFocus />);
|
||||
const filterInput = screen.getByPlaceholderText('Search');
|
||||
|
||||
// FilterInput should not have stolen focus from the already-focused input
|
||||
expect(document.activeElement).not.toBe(filterInput);
|
||||
expect(document.activeElement).toBe(otherInput);
|
||||
} finally {
|
||||
document.body.removeChild(otherInput);
|
||||
}
|
||||
});
|
||||
|
||||
15
superset-frontend/src/explore/components/DataTableControl/index.tsx
Normal file → Executable file
15
superset-frontend/src/explore/components/DataTableControl/index.tsx
Normal file → Executable file
@@ -98,9 +98,20 @@ export const FilterInput = ({
|
||||
const inputRef: RefObject<any> = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Focus the input element when the component mounts
|
||||
if (inputRef.current && shouldFocus) {
|
||||
inputRef.current.focus();
|
||||
// Skip auto-focus only when an editable element already has focus (e.g.
|
||||
// user is typing in a form control when this pane remounts after a data
|
||||
// refresh). Non-editable focused elements like tabs/buttons still allow
|
||||
// auto-focus so the search box focuses on first open.
|
||||
const activeEl = document.activeElement;
|
||||
const editableFocused =
|
||||
activeEl instanceof HTMLElement &&
|
||||
(activeEl.tagName === 'INPUT' ||
|
||||
activeEl.tagName === 'TEXTAREA' ||
|
||||
activeEl.isContentEditable);
|
||||
if (!editableFocused) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -778,7 +778,11 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) {
|
||||
suffix={
|
||||
<InputIconAlignment>
|
||||
{searchInputValue && (
|
||||
<Icons.CloseOutlined iconSize="m" onClick={stopSearching} />
|
||||
<Icons.CloseOutlined
|
||||
iconSize="m"
|
||||
onClick={stopSearching}
|
||||
aria-label={t('Clear search')}
|
||||
/>
|
||||
)}
|
||||
</InputIconAlignment>
|
||||
}
|
||||
|
||||
@@ -243,6 +243,7 @@ export const accessTokenField = ({
|
||||
validationErrors,
|
||||
db,
|
||||
isEditMode,
|
||||
isValidating,
|
||||
default_value,
|
||||
description,
|
||||
}: FieldPropTypes) => (
|
||||
@@ -250,6 +251,7 @@ export const accessTokenField = ({
|
||||
id="access_token"
|
||||
name="access_token"
|
||||
required={required}
|
||||
isValidating={isValidating}
|
||||
visibilityToggle={!isEditMode}
|
||||
value={db?.parameters?.access_token}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const TableCatalog = ({
|
||||
getValidation,
|
||||
validationErrors,
|
||||
db,
|
||||
isValidating,
|
||||
isPublic = true,
|
||||
}: FieldPropTypes) => {
|
||||
const tableCatalog = db?.catalog || [];
|
||||
@@ -53,6 +54,7 @@ export const TableCatalog = ({
|
||||
<ValidatedInput
|
||||
className="catalog-name-input"
|
||||
required={required}
|
||||
isValidating={isValidating}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={catalogError[idx]?.name}
|
||||
placeholder={t('Enter a name for this sheet')}
|
||||
@@ -86,6 +88,7 @@ export const TableCatalog = ({
|
||||
<ValidatedInput
|
||||
className="catalog-name-url"
|
||||
required={required}
|
||||
isValidating={isValidating}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={catalogError[idx]?.url}
|
||||
placeholder={t('Paste the shareable Google Sheet URL here')}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user