mirror of
https://github.com/apache/superset.git
synced 2026-07-06 23:05:36 +00:00
Compare commits
99 Commits
fix/export
...
fix-databa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b304f33a9b | ||
|
|
83d93b8b42 | ||
|
|
06f421ed4a | ||
|
|
dc671ae44a | ||
|
|
c49d01223c | ||
|
|
69f052985f | ||
|
|
f4cc871f8c | ||
|
|
2f11240339 | ||
|
|
a7a051777c | ||
|
|
b4218136be | ||
|
|
fd8d7009a5 | ||
|
|
beff76cb4d | ||
|
|
d6b31376a5 | ||
|
|
1575b83f96 | ||
|
|
83826839a0 | ||
|
|
a8ab5f973d | ||
|
|
7cdfe3ffe7 | ||
|
|
9a24d42d65 | ||
|
|
3303d4e09e | ||
|
|
8229c0183d | ||
|
|
ea40b87e2f | ||
|
|
dc739e4fb9 | ||
|
|
a687e87b2c | ||
|
|
d91c5cf09b | ||
|
|
3ac19470ec | ||
|
|
687fafd424 | ||
|
|
8fcc0f8b48 | ||
|
|
e08c2c12da | ||
|
|
1e50316bcc | ||
|
|
3e152d9bb7 | ||
|
|
004c401c97 | ||
|
|
a9aabdaedf | ||
|
|
210389478b | ||
|
|
43f2816240 | ||
|
|
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]
|
||||
|
||||
|
||||
35
UPDATING.md
35
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,28 @@ 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.
|
||||
|
||||
### Soft delete and restore for charts
|
||||
|
||||
**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/chart/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
|
||||
|
||||
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If charts are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live charts in all lists, lookups, and relationship loads (including dashboards that contained them). The `POST /<uuid>/restore` endpoint and the `chart_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
|
||||
|
||||
With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the chart (the bulk-delete endpoint behaves the same way). The row is marked with a `deleted_at` timestamp and hidden from all list, detail, and lookup endpoints. Charts in this state are excluded from default queries and from relationship loads (e.g. `dashboard.slices`).
|
||||
|
||||
**Operational notes:** a report schedule whose target chart is soft-deleted now fails its runs with an explicit error ("The chart this report targets was deleted...") until the chart is restored or the report re-pointed — chart deletion is blocked while a report references the chart, but a validate/commit race or a flag toggle can still produce this state. Dashboards **preserve** their membership rows for soft-deleted charts: saving a dashboard does not sever a trashed member, and restoring the chart re-attaches it to its dashboards.
|
||||
|
||||
**New endpoint** — `POST /api/v1/chart/<uuid>/restore` clears `deleted_at` and returns the chart to active state. Requires `can_write on Chart` and ownership of the row (or admin). Soft-deleted charts can also be surfaced in the list endpoint via the new `chart_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to charts they own — the same audience that can restore them.
|
||||
|
||||
**Permissions migration:** existing role grants of `can_write on Chart` cover the new restore endpoint automatically; no role migration is required.
|
||||
|
||||
**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_slices_deleted_at`) to the `slices` table. The column add is instant; the index build runs inline (no `CONCURRENTLY`) and may briefly block writes on the `slices` table (INSERT/UPDATE/DELETE are queued while the index builds; reads are unaffected) on large Postgres deployments. MySQL InnoDB builds the index online (no blocking).
|
||||
|
||||
**Rollback note:** if the application code is rolled back after charts have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column, so any not-yet-restored soft-deleted charts silently become live, active charts with no record they were ever deleted. Reconcile the trash (restore or hard-delete each row) *before* downgrading, and disable the `SOFT_DELETE` flag first so no new soft deletes land mid-rollback.
|
||||
|
||||
**Importer behavior:** importing a chart YAML whose UUID matches an existing **soft-deleted** chart is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active chart imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted chart's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all out-of-archive references (`dashboard_slices` junctions, `report.chart_id`, tag rows). The operation is permission-gated: non-owners get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
|
||||
- [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,25 +58,15 @@
|
||||
"@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",
|
||||
"antd": "^6.5.0",
|
||||
"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",
|
||||
"js-yaml": "^5.1.0",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.0",
|
||||
"js-yaml": "^5.2.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -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",
|
||||
@@ -107,10 +97,10 @@
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.7.0",
|
||||
"prettier": "^3.8.4",
|
||||
"prettier": "^3.9.1",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"webpack": "^5.107.2"
|
||||
"webpack": "^5.108.2"
|
||||
},
|
||||
"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
|
||||
|
||||
1810
docs/yarn.lock
1810
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
|
||||
|
||||
1659
superset-frontend/package-lock.json
generated
1659
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -192,18 +192,19 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"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",
|
||||
"query-string": "9.4.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.3.0",
|
||||
"react-arborist": "^3.10.5",
|
||||
"react-arborist": "^3.12.0",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -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",
|
||||
@@ -297,20 +298,19 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"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": "^10.6.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
@@ -342,10 +342,11 @@
|
||||
"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",
|
||||
"prettier": "3.8.4",
|
||||
"prettier": "3.9.3",
|
||||
"prettier-plugin-packagejson": "^3.0.2",
|
||||
"process": "^0.11.10",
|
||||
"react-dnd-test-backend": "^16.0.1",
|
||||
@@ -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",
|
||||
|
||||
@@ -122,6 +122,6 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -173,3 +173,82 @@ test('resetTranslation does nothing when not yet configured', () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// --- autoConfigureFromWindow ----------------------------------------------
|
||||
// These cover the bootstrap-injection path used to dodge the
|
||||
// module-level `const X = t(...)` race across code-split chunks
|
||||
// (upstream issue #35330).
|
||||
|
||||
test('t() self-configures from window.__SUPERSET_LANGUAGE_PACK__ on first call', () => {
|
||||
jest.isolateModules(() => {
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = {
|
||||
domain: 'superset',
|
||||
locale_data: {
|
||||
superset: {
|
||||
'': {
|
||||
domain: 'superset',
|
||||
lang: 'fr',
|
||||
plural_forms: 'nplurals=2; plural=(n > 1);',
|
||||
},
|
||||
hello: ['bonjour'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { t } = require('./TranslatorSingleton');
|
||||
expect(t('hello')).toBe('bonjour');
|
||||
// No "should call configure" warning because we self-configured first.
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
delete window.__SUPERSET_LANGUAGE_PACK__;
|
||||
});
|
||||
});
|
||||
|
||||
test('t() falls back to msgid when window has no language pack', () => {
|
||||
jest.isolateModules(() => {
|
||||
delete window.__SUPERSET_LANGUAGE_PACK__;
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { t } = require('./TranslatorSingleton');
|
||||
expect(t('hello')).toBe('hello');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/was called before configure\(\)/),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
test('explicit configure() takes precedence over window pack', () => {
|
||||
jest.isolateModules(() => {
|
||||
window.__SUPERSET_LANGUAGE_PACK__ = {
|
||||
domain: 'superset',
|
||||
locale_data: {
|
||||
superset: {
|
||||
'': {
|
||||
domain: 'superset',
|
||||
lang: 'fr',
|
||||
plural_forms: 'nplurals=2; plural=(n > 1);',
|
||||
},
|
||||
hello: ['bonjour'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { configure, t } = require('./TranslatorSingleton');
|
||||
configure({
|
||||
languagePack: {
|
||||
domain: 'superset',
|
||||
locale_data: {
|
||||
superset: {
|
||||
'': {
|
||||
domain: 'superset',
|
||||
lang: 'es',
|
||||
plural_forms: 'nplurals=2; plural=(n != 1);',
|
||||
},
|
||||
hello: ['hola'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(t('hello')).toBe('hola');
|
||||
delete window.__SUPERSET_LANGUAGE_PACK__;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,31 @@ function configure(config?: TranslatorConfig) {
|
||||
return singleton;
|
||||
}
|
||||
|
||||
// When webpack splits @apache-superset/core across chunks, each
|
||||
// chunk-local copy of this module has its own `singleton` and
|
||||
// `isConfigured` state. The first `t()` call in a late chunk hits a
|
||||
// fresh, unconfigured Translator and returns the English msgid. To
|
||||
// make translations survive chunk duplication, the HTML template
|
||||
// stashes the language pack on window and we self-configure from it
|
||||
// on first access. See upstream issue #35330.
|
||||
declare global {
|
||||
interface Window {
|
||||
__SUPERSET_LANGUAGE_PACK__?: TranslatorConfig['languagePack'];
|
||||
}
|
||||
}
|
||||
|
||||
function autoConfigureFromWindow() {
|
||||
if (isConfigured) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
const pack = window.__SUPERSET_LANGUAGE_PACK__;
|
||||
if (pack) {
|
||||
configure({ languagePack: pack });
|
||||
}
|
||||
}
|
||||
|
||||
function getInstance() {
|
||||
autoConfigureFromWindow();
|
||||
|
||||
if (typeof singleton === 'undefined') {
|
||||
singleton = new Translator();
|
||||
}
|
||||
@@ -85,11 +109,16 @@ function addLocaleData(data: LocaleData) {
|
||||
}
|
||||
|
||||
function t(input: string, ...args: unknown[]) {
|
||||
// Self-configure from the bootstrap-injected window pack before deciding
|
||||
// whether to warn, so a chunk-local copy that hasn't seen configure() yet
|
||||
// doesn't warn (or fall back to English) when the pack is available.
|
||||
autoConfigureFromWindow();
|
||||
if (!isConfigured) warnPreConfigure('t', input);
|
||||
return getInstance().translate(input, ...args);
|
||||
}
|
||||
|
||||
function tn(key: string, ...args: unknown[]) {
|
||||
autoConfigureFromWindow();
|
||||
if (!isConfigured) warnPreConfigure('tn', key);
|
||||
return getInstance().translateWithNumber(key, ...args);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@types/react": "*",
|
||||
"lodash": "^4.18.1",
|
||||
"tinycolor2": "*",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
import { AgGrid } from '../components/core/AgGrid';
|
||||
|
||||
/**
|
||||
* Explore Page object
|
||||
@@ -30,6 +31,11 @@ export class ExplorePage {
|
||||
DATASOURCE_CONTROL: '[data-test="datasource-control"]',
|
||||
VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
|
||||
CHART_CONTAINER: '[data-test="chart-container"]',
|
||||
// The bottom data panel (DataTablesPane / SouthPane) with Results/Samples tabs
|
||||
SOUTH_PANE: '[data-test="some-purposeful-instance"]',
|
||||
EXPAND_DATA_PANEL: '[aria-label="Expand data panel"]',
|
||||
RESULTS_TAB: '[data-node-key="results"]',
|
||||
ACTIVE_TABPANE: '.ant-tabs-tabpane-active',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -106,4 +112,44 @@ export class ExplorePage {
|
||||
getVizSwitcher(): Locator {
|
||||
return this.page.locator(ExplorePage.SELECTORS.VIZ_SWITCHER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the bottom data panel if it is currently collapsed.
|
||||
* Safe to call when already expanded (no-op).
|
||||
*/
|
||||
async expandDataPanel(): Promise<void> {
|
||||
const expandButton = this.page.locator(
|
||||
ExplorePage.SELECTORS.EXPAND_DATA_PANEL,
|
||||
);
|
||||
if (await expandButton.isVisible().catch(() => false)) {
|
||||
await expandButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the bottom data panel and activates the "Results" tab. Clicking the
|
||||
* already-active tab collapses the panel, so the click is guarded.
|
||||
*/
|
||||
async openResultsTab(): Promise<void> {
|
||||
await this.expandDataPanel();
|
||||
const resultsTab = this.page
|
||||
.locator(ExplorePage.SELECTORS.SOUTH_PANE)
|
||||
.locator(ExplorePage.SELECTORS.RESULTS_TAB);
|
||||
const className = (await resultsTab.getAttribute('class')) ?? '';
|
||||
if (!className.includes('ant-tabs-tab-active')) {
|
||||
await resultsTab.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an AgGrid wrapper around the currently active Results tab grid.
|
||||
*/
|
||||
getResultsGrid(): AgGrid {
|
||||
const grid = this.page
|
||||
.locator(ExplorePage.SELECTORS.SOUTH_PANE)
|
||||
.locator(ExplorePage.SELECTORS.ACTIVE_TABPANE)
|
||||
.locator('[role="grid"]')
|
||||
.first();
|
||||
return new AgGrid(this.page, grid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression coverage for #38152 (PR #38165): the Explore "Results" tab reuses
|
||||
* the chart's already-fetched query data instead of firing a second, identical
|
||||
* request to /api/v1/chart/data. The duplicate only exists across the real
|
||||
* backend round-trip; the reuse-vs-fallback and row-limit slicing logic is
|
||||
* unit-tested in useResultsPane.test.tsx, so this E2E asserts only the
|
||||
* end-to-end win: opening Results issues no extra chart/data request.
|
||||
*
|
||||
* Lives under tests/experimental/ until proven stable in CI; run with:
|
||||
* INCLUDE_EXPERIMENTAL=true npm run playwright:test \
|
||||
* tests/experimental/explore/results-tab-reuse.spec.ts -- --headed
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../../helpers/fixtures';
|
||||
import { apiPostChart } from '../../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../../helpers/api/dataset';
|
||||
import { ExplorePage } from '../../../pages/ExplorePage';
|
||||
import { TIMEOUT } from '../../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const CHART_DATA_PATH = '/api/v1/chart/data';
|
||||
|
||||
testWithAssets(
|
||||
'Results tab reuses chart data without a duplicate query (#38165)',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
let chartDataRequests = 0;
|
||||
page.on('request', request => {
|
||||
if (
|
||||
request.method() === 'POST' &&
|
||||
request.url().includes(CHART_DATA_PATH)
|
||||
) {
|
||||
chartDataRequests += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const datasetId = dataset.id;
|
||||
|
||||
const params = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: ['name', 'gender', 'num'],
|
||||
adhoc_filters: [],
|
||||
order_by_cols: [],
|
||||
row_limit: 1000,
|
||||
server_pagination: false,
|
||||
};
|
||||
const chartResp = await apiPostChart(page, {
|
||||
slice_name: `results_tab_reuse_${Date.now()}`,
|
||||
viz_type: 'table',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(params),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartBody = await chartResp.json();
|
||||
const chartId: number = chartBody.result?.id ?? chartBody.id;
|
||||
expect(chartId, 'chart creation should return an id').toBeTruthy();
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
// Wait for the chart query to finish so its result is in Redux; opening
|
||||
// Results before that races and falls back to its own request.
|
||||
const explorePage = new ExplorePage(page);
|
||||
const chartQueryFinished = page.waitForResponse(
|
||||
response =>
|
||||
response.request().method() === 'POST' &&
|
||||
response.url().includes(CHART_DATA_PATH),
|
||||
{ timeout: TIMEOUT.API_RESPONSE },
|
||||
);
|
||||
await explorePage.goto(chartId);
|
||||
await chartQueryFinished;
|
||||
|
||||
const chartContainer = explorePage.getChartContainer();
|
||||
await chartContainer.waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
await chartContainer
|
||||
.getByRole('status', { name: 'Loading' })
|
||||
.waitFor({ state: 'hidden', timeout: TIMEOUT.API_RESPONSE })
|
||||
.catch(() => {}); // spinner may have already cleared
|
||||
|
||||
expect(chartDataRequests).toBeGreaterThanOrEqual(1);
|
||||
const requestsAfterChartLoad = chartDataRequests;
|
||||
|
||||
await explorePage.openResultsTab();
|
||||
const grid = explorePage.getResultsGrid();
|
||||
await grid.waitForRows({ timeout: TIMEOUT.API_RESPONSE });
|
||||
expect(await grid.getRowCount()).toBeGreaterThan(0);
|
||||
|
||||
// Allow time for an unwanted duplicate request, then assert none fired.
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1500);
|
||||
expect(
|
||||
chartDataRequests,
|
||||
'opening Results must not trigger a second chart/data query',
|
||||
).toBe(requestsAfterChartLoad);
|
||||
},
|
||||
);
|
||||
@@ -37,7 +37,7 @@
|
||||
"dompurify": "^3.4.11",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
|
||||
@@ -86,6 +86,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
onChartStateChange,
|
||||
chartState,
|
||||
metricSqlExpressions,
|
||||
rawSummaryColumns,
|
||||
showNumberedColumn,
|
||||
} = props;
|
||||
|
||||
@@ -113,26 +114,58 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
}
|
||||
}, [columns]);
|
||||
|
||||
// A single effect owns every ownState write derived from render state.
|
||||
// updateTableOwnState replaces ownState wholesale, so separate effects that
|
||||
// each spread serverPaginationData in the same render would clobber one
|
||||
// another's keys: clamping the current page, priming the raw-mode summary
|
||||
// columns and nudging a re-query for missing totals must be one combined
|
||||
// delta.
|
||||
useEffect(() => {
|
||||
if (!serverPagination || !serverPaginationData || !rowCount) return;
|
||||
const nextOwnState = { ...serverPaginationData };
|
||||
let changed = false;
|
||||
|
||||
const currentPage = serverPaginationData.currentPage ?? 0;
|
||||
const currentPageSize = serverPaginationData.pageSize ?? serverPageLength;
|
||||
const totalPages = Math.ceil(rowCount / currentPageSize);
|
||||
if (serverPagination && serverPaginationData && rowCount !== undefined) {
|
||||
const currentPage = serverPaginationData.currentPage ?? 0;
|
||||
const currentPageSize = serverPaginationData.pageSize ?? serverPageLength;
|
||||
const totalPages = Math.ceil(rowCount / currentPageSize);
|
||||
// An empty result set clamps to page zero; a shrunken one clamps to its
|
||||
// last remaining page.
|
||||
const clampedPage = Math.max(0, Math.min(currentPage, totalPages - 1));
|
||||
if (clampedPage !== currentPage) {
|
||||
nextOwnState.currentPage = clampedPage;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPage >= totalPages && totalPages > 0) {
|
||||
const validPage = Math.max(0, totalPages - 1);
|
||||
const modifiedOwnState = {
|
||||
...serverPaginationData,
|
||||
currentPage: validPage,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
const primed = (serverPaginationData?.rawSummaryColumns ?? []) as string[];
|
||||
const requested = Boolean(serverPaginationData?.totalsRequested);
|
||||
if (isRawRecords && showTotals && !isEqual(primed, rawSummaryColumns)) {
|
||||
nextOwnState.rawSummaryColumns = rawSummaryColumns;
|
||||
changed = true;
|
||||
}
|
||||
// A renderTrigger toggle re-renders without re-querying; requesting totals
|
||||
// through ownState dispatches the standard re-query whose buildQuery
|
||||
// carries the totals query for the active mode.
|
||||
if (showTotals && totals === undefined && !requested) {
|
||||
nextOwnState.totalsRequested = true;
|
||||
changed = true;
|
||||
} else if (!showTotals && requested) {
|
||||
nextOwnState.totalsRequested = false;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
updateTableOwnState(setDataMask, nextOwnState);
|
||||
}
|
||||
}, [
|
||||
rowCount,
|
||||
serverPagination,
|
||||
serverPaginationData,
|
||||
rowCount,
|
||||
serverPageLength,
|
||||
isRawRecords,
|
||||
showTotals,
|
||||
totals,
|
||||
rawSummaryColumns,
|
||||
serverPaginationData,
|
||||
setDataMask,
|
||||
]);
|
||||
|
||||
@@ -447,7 +480,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
isUsingTimeComparison ? renderTimeComparisonVisibility : () => null
|
||||
}
|
||||
cleanedTotals={totals || {}}
|
||||
showTotals={showTotals}
|
||||
showTotals={
|
||||
showTotals && totals !== undefined && Object.keys(totals).length > 0
|
||||
}
|
||||
width={width}
|
||||
onColumnStateChange={handleColumnStateChange}
|
||||
chartState={chartState}
|
||||
|
||||
@@ -624,11 +624,28 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
|
||||
// Create totals query AFTER all filters (including AG Grid filters) are applied
|
||||
// This ensures we can properly exclude AG Grid WHERE filters from the totals
|
||||
if (
|
||||
// In raw records mode the summary is a SUM over the numeric columns primed
|
||||
// into ownState by the chart (see rawSummaryColumns in transformProps).
|
||||
// Own state can outlive a datasource or column-selection change, so bound
|
||||
// the primed summary columns to the current raw selection: a stale name
|
||||
// must never reach a SUM metric or the whole chart query fails before the
|
||||
// chart can re-prime its own state.
|
||||
const selectedRawColumns = new Set(
|
||||
ensureIsArray(formData.all_columns).map(getColumnLabel),
|
||||
);
|
||||
const rawSummaryColumns =
|
||||
queryMode === QueryMode.Raw && formData.show_totals
|
||||
? ensureIsArray(
|
||||
ownState.rawSummaryColumns as string[] | undefined,
|
||||
).filter(columnName => selectedRawColumns.has(columnName))
|
||||
: [];
|
||||
const showAggregateTotals = Boolean(
|
||||
metrics?.length &&
|
||||
formData.show_totals &&
|
||||
queryMode === QueryMode.Aggregate
|
||||
) {
|
||||
queryMode === QueryMode.Aggregate,
|
||||
);
|
||||
|
||||
if (showAggregateTotals || rawSummaryColumns.length > 0) {
|
||||
// Create a copy of extras without the AG Grid WHERE clause
|
||||
// AG Grid filters in extras.where can reference calculated columns
|
||||
// which aren't available in the totals subquery
|
||||
@@ -661,6 +678,14 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
extraQueries.push({
|
||||
...queryObject,
|
||||
columns: [],
|
||||
...(rawSummaryColumns.length > 0 && {
|
||||
metrics: rawSummaryColumns.map(columnName => ({
|
||||
expressionType: 'SIMPLE' as const,
|
||||
aggregate: 'SUM' as const,
|
||||
column: { column_name: columnName },
|
||||
label: columnName,
|
||||
})),
|
||||
}),
|
||||
extras: totalsExtras, // Use extras with AG Grid WHERE removed
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
|
||||
@@ -425,21 +425,6 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'show_totals',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show summary'),
|
||||
default: false,
|
||||
description: t(
|
||||
'Show total aggregations of selected metrics. Note that row limit does not apply to the result.',
|
||||
),
|
||||
visibility: isAggMode,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -496,6 +481,20 @@ const config: ControlPanelConfig = {
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'show_totals',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show summary'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Show a summary row of total aggregations: the selected metrics in aggregate mode, or the sum of numeric columns in raw records mode. Note that row limit does not apply to the result.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'show_numbered_column',
|
||||
|
||||
@@ -714,12 +714,11 @@ const transformProps = (
|
||||
|
||||
const hasPageLength = isPositiveNumber(pageLength);
|
||||
|
||||
const totals =
|
||||
showTotals && queryMode === QueryMode.Aggregate
|
||||
? isUsingTimeComparison
|
||||
? processComparisonTotals(comparisonSuffix, totalQuery?.data)
|
||||
: totalQuery?.data[0]
|
||||
: undefined;
|
||||
const totals = showTotals
|
||||
? isUsingTimeComparison
|
||||
? processComparisonTotals(comparisonSuffix, totalQuery?.data)
|
||||
: totalQuery?.data[0]
|
||||
: undefined;
|
||||
|
||||
// Map saved metric/calculated column labels to their SQL expressions for filter resolution
|
||||
const metricSqlExpressions: Record<string, string> = {};
|
||||
@@ -737,6 +736,21 @@ const transformProps = (
|
||||
}
|
||||
});
|
||||
|
||||
// Numeric raw-records columns eligible for the summary row. Only columns
|
||||
// backed by a dataset (physical or calculated) column can be summed
|
||||
// server-side; free-form SQL expression columns are excluded.
|
||||
const datasetColumnNames = new Set(
|
||||
chartProps.datasource.columns
|
||||
.map(col => col.column_name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
);
|
||||
const rawSummaryColumns =
|
||||
queryMode === QueryMode.Raw && showTotals
|
||||
? columns
|
||||
.filter(col => col.isNumeric && datasetColumnNames.has(col.key))
|
||||
.map(col => col.key)
|
||||
: [];
|
||||
|
||||
// Strip saved filter from chartState after initial application to prevent re-injection
|
||||
let chartState = serverPaginationData?.chartState as
|
||||
| AgGridChartState
|
||||
@@ -784,6 +798,7 @@ const transformProps = (
|
||||
basicColorFormatters,
|
||||
formData,
|
||||
metricSqlExpressions,
|
||||
rawSummaryColumns,
|
||||
chartState,
|
||||
onChartStateChange,
|
||||
showNumberedColumn,
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface AgGridTableChartTransformedProps<
|
||||
basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[];
|
||||
formData: TableChartFormData;
|
||||
metricSqlExpressions: Record<string, string>;
|
||||
rawSummaryColumns: string[];
|
||||
onChartStateChange?: (chartState: JsonObject) => void;
|
||||
chartState?: AgGridChartState;
|
||||
showNumberedColumn: boolean;
|
||||
|
||||
@@ -27,6 +27,8 @@ interface TableOwnState {
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
searchText?: string;
|
||||
sortBy?: SortByItem[];
|
||||
rawSummaryColumns?: string[];
|
||||
totalsRequested?: boolean;
|
||||
}
|
||||
|
||||
export const updateTableOwnState = (
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@superset-ui/core/spec';
|
||||
import { QueryMode, TimeGranularity, SMART_DATE_ID } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import AgGridTableChart from '../src/AgGridTableChart';
|
||||
import transformProps from '../src/transformProps';
|
||||
@@ -326,6 +327,61 @@ test('AgGridTableChart handles raw records mode', async () => {
|
||||
expect(headerCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const rawSummaryProps = {
|
||||
...testData.basic,
|
||||
rawFormData: {
|
||||
...testData.basic.rawFormData,
|
||||
query_mode: QueryMode.Raw,
|
||||
show_totals: true,
|
||||
},
|
||||
datasource: {
|
||||
...testData.basic.datasource,
|
||||
columns: [{ column_name: 'name' }, { column_name: 'sum__num' }],
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
...testData.basic.queriesData[0],
|
||||
colnames: [...testData.basic.queriesData[0].colnames, 'num_free'],
|
||||
coltypes: [
|
||||
...testData.basic.queriesData[0].coltypes,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
data: testData.basic.queriesData[0].data,
|
||||
},
|
||||
{ ...testData.basic.queriesData[0], data: [{ sum__num: 12345 }] },
|
||||
],
|
||||
};
|
||||
|
||||
test('transformProps derives numeric dataset columns as rawSummaryColumns in raw mode', () => {
|
||||
const transformed = transformProps(rawSummaryProps);
|
||||
// 'sum__num' is the only numeric column of the selection that is both
|
||||
// numeric and backed by a dataset column: 'num_free' is Numeric but not
|
||||
// dataset-backed, and 'name' is dataset-backed but not numeric.
|
||||
expect(transformed.rawSummaryColumns).toEqual(['sum__num']);
|
||||
});
|
||||
|
||||
test('transformProps surfaces raw records totals when the summary is on', () => {
|
||||
const transformed = transformProps(rawSummaryProps);
|
||||
expect(transformed.totals).toEqual({ sum__num: 12345 });
|
||||
});
|
||||
|
||||
test('transformProps leaves totals undefined in raw mode when the summary is off', () => {
|
||||
const transformed = transformProps({
|
||||
...rawSummaryProps,
|
||||
rawFormData: {
|
||||
...rawSummaryProps.rawFormData,
|
||||
show_totals: false,
|
||||
},
|
||||
});
|
||||
expect(transformed.totals).toBeUndefined();
|
||||
expect(transformed.rawSummaryColumns).toEqual([]);
|
||||
});
|
||||
|
||||
test('transformProps returns empty rawSummaryColumns in aggregate mode', () => {
|
||||
const transformed = transformProps(testData.basic);
|
||||
expect(transformed.rawSummaryColumns).toEqual([]);
|
||||
});
|
||||
|
||||
test('AgGridTableChart corrects invalid page number when currentPage >= totalPages', async () => {
|
||||
const props = transformProps({
|
||||
...testData.basic,
|
||||
@@ -357,3 +413,316 @@ test('AgGridTableChart corrects invalid page number when currentPage >= totalPag
|
||||
expect(mockSetDataMask).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart primes raw summary columns into own state', async () => {
|
||||
const props = transformProps(rawSummaryProps);
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
rawSummaryColumns: ['sum__num'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart does not prime summary columns when the summary is off', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
rawFormData: {
|
||||
...rawSummaryProps.rawFormData,
|
||||
show_totals: false,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.ag-container')).toBeInTheDocument();
|
||||
});
|
||||
const primedCalls = mockSetDataMask.mock.calls.filter(
|
||||
([arg]) => arg?.ownState?.rawSummaryColumns,
|
||||
);
|
||||
expect(primedCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('AgGridTableChart clears a stale summary prime when no numeric columns remain', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
datasource: {
|
||||
...testData.basic.datasource,
|
||||
columns: [{ column_name: 'name' }],
|
||||
},
|
||||
});
|
||||
props.serverPaginationData = { rawSummaryColumns: ['sum__num'] };
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({ rawSummaryColumns: [] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart requests aggregate totals when the summary toggles on without data', async () => {
|
||||
const props = transformProps({
|
||||
...testData.basic,
|
||||
rawFormData: {
|
||||
...testData.basic.rawFormData,
|
||||
show_totals: true,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({ totalsRequested: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart clears the aggregate totals request when the summary is off', async () => {
|
||||
const props = transformProps(testData.basic);
|
||||
props.serverPaginationData = { totalsRequested: true };
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
totalsRequested: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart clamps the page to zero when the result set is empty', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
queriesData: [rawSummaryProps.queriesData[0]],
|
||||
});
|
||||
props.serverPagination = true;
|
||||
props.rowCount = 0;
|
||||
props.serverPaginationData = { currentPage: 5, pageSize: 20 };
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Zero rows means zero pages; a stale page index must reset so row_offset
|
||||
// does not keep pointing past the (now empty) result set. Asserting the
|
||||
// totals keys alongside pins the write to the unified effect rather than
|
||||
// the pagination footer's own page reset.
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
currentPage: 0,
|
||||
rawSummaryColumns: ['sum__num'],
|
||||
totalsRequested: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart merges the page clamp and totals request into one own-state write', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
queriesData: [rawSummaryProps.queriesData[0]],
|
||||
});
|
||||
props.serverPagination = true;
|
||||
props.rowCount = 41;
|
||||
props.serverPaginationData = { currentPage: 5, pageSize: 20 };
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// 41 rows at page size 20 leave 3 pages, so page 5 must clamp to 2 in the
|
||||
// same write that primes the summary columns and requests totals; separate
|
||||
// writes would overwrite each other's keys.
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
currentPage: 2,
|
||||
rawSummaryColumns: ['sum__num'],
|
||||
totalsRequested: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart re-requests raw totals when toggled back on with a matching prime', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
queriesData: [rawSummaryProps.queriesData[0]],
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
serverPaginationData={{ rawSummaryColumns: ['sum__num'] }}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
rawSummaryColumns: ['sum__num'],
|
||||
totalsRequested: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('AgGridTableChart pins no summary row when totals come back empty', async () => {
|
||||
const props = transformProps({
|
||||
...testData.basic,
|
||||
rawFormData: {
|
||||
...testData.basic.rawFormData,
|
||||
show_totals: true,
|
||||
},
|
||||
});
|
||||
props.showTotals = true;
|
||||
// An empty totals object (e.g. a time-comparison totals result with no
|
||||
// rows) carries nothing to display and must not pin a blank row.
|
||||
props.totals = {};
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.ag-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const pinnedRows = document.querySelectorAll('.ag-floating-bottom .ag-row');
|
||||
expect(pinnedRows.length).toBe(0);
|
||||
});
|
||||
|
||||
test('AgGridTableChart pins no summary row when totals are absent', async () => {
|
||||
const props = transformProps({
|
||||
...rawSummaryProps,
|
||||
datasource: {
|
||||
...testData.basic.datasource,
|
||||
columns: [{ column_name: 'name' }],
|
||||
},
|
||||
queriesData: [rawSummaryProps.queriesData[0]],
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<AgGridTableChart
|
||||
{...props}
|
||||
setDataMask={mockSetDataMask}
|
||||
slice_id={1}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.ag-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const pinnedRows = document.querySelectorAll('.ag-floating-bottom .ag-row');
|
||||
expect(pinnedRows.length).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1432,4 +1432,133 @@ describe('plugin-chart-ag-grid-table', () => {
|
||||
expect(query.extras?.where || undefined).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQuery - raw records summary totals', () => {
|
||||
const rawFormData: TableChartFormData = {
|
||||
viz_type: VizType.Table,
|
||||
datasource: '11__table',
|
||||
query_mode: QueryMode.Raw,
|
||||
all_columns: ['name', 'num'],
|
||||
show_totals: true,
|
||||
};
|
||||
|
||||
test('drops summary columns missing from the raw selection', () => {
|
||||
// Own state can outlive a datasource or column-selection change; a
|
||||
// persisted name absent from all_columns must never reach a SUM metric.
|
||||
const { queries } = buildQuery(rawFormData, {
|
||||
ownState: { rawSummaryColumns: ['num', 'ghost_col'] },
|
||||
});
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate: 'SUM',
|
||||
column: { column_name: 'num' },
|
||||
label: 'num',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps calculated dataset columns in the totals metrics', () => {
|
||||
// Calculated columns are dataset-backed and resolve server-side from
|
||||
// the column name alone (standard SIMPLE adhoc-metric resolution); the
|
||||
// selection intersection must not assume physical columns only.
|
||||
const { queries } = buildQuery(
|
||||
{ ...rawFormData, all_columns: ['name', 'num', 'boys_ratio_calc'] },
|
||||
{ ownState: { rawSummaryColumns: ['num', 'boys_ratio_calc'] } },
|
||||
);
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1].metrics).toEqual([
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate: 'SUM',
|
||||
column: { column_name: 'num' },
|
||||
label: 'num',
|
||||
},
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate: 'SUM',
|
||||
column: { column_name: 'boys_ratio_calc' },
|
||||
label: 'boys_ratio_calc',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('adds no totals query when every primed column left the selection', () => {
|
||||
const { queries } = buildQuery(rawFormData, {
|
||||
ownState: { rawSummaryColumns: ['ghost_col'] },
|
||||
});
|
||||
|
||||
expect(queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('adds a SUM totals query when summary columns are primed', () => {
|
||||
const { queries } = buildQuery(rawFormData, {
|
||||
ownState: { rawSummaryColumns: ['num'] },
|
||||
});
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
columns: [],
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
metrics: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate: 'SUM',
|
||||
column: { column_name: 'num' },
|
||||
label: 'num',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(queries[1].orderby).toBeUndefined();
|
||||
});
|
||||
|
||||
test('adds no totals query without primed summary columns', () => {
|
||||
const { queries } = buildQuery(rawFormData, { ownState: {} });
|
||||
expect(queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('adds no totals query when show_totals is off', () => {
|
||||
const { queries } = buildQuery(
|
||||
{ ...rawFormData, show_totals: false },
|
||||
{ ownState: { rawSummaryColumns: ['num'] } },
|
||||
);
|
||||
expect(queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps the totals query last with server pagination', () => {
|
||||
const { queries } = buildQuery(
|
||||
{ ...rawFormData, server_pagination: true },
|
||||
{ ownState: { rawSummaryColumns: ['num'] } },
|
||||
);
|
||||
|
||||
expect(queries).toHaveLength(3);
|
||||
expect(queries[1].is_rowcount).toBe(true);
|
||||
expect(queries[2].columns).toEqual([]);
|
||||
expect(queries[2].row_limit).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps aggregate-mode totals metrics untouched', () => {
|
||||
const { queries } = buildQuery(
|
||||
{
|
||||
viz_type: VizType.Table,
|
||||
datasource: '11__table',
|
||||
query_mode: QueryMode.Aggregate,
|
||||
groupby: ['state'],
|
||||
metrics: ['count'],
|
||||
show_totals: true,
|
||||
},
|
||||
{ ownState: {} },
|
||||
);
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1].columns).toEqual([]);
|
||||
expect(queries[1].metrics).toEqual(['count']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ControlPanelsContainerProps,
|
||||
ControlState,
|
||||
CustomControlItem,
|
||||
isCustomControlItem,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import config from '../src/controlPanel';
|
||||
|
||||
@@ -75,3 +76,38 @@ test('time_grain_sqla visibility should be case-insensitive', () => {
|
||||
expect(vis(mkProps(['ORDERDATE']), controlState)).toBe(true);
|
||||
expect(vis(mkProps(['some_other_col']), controlState)).toBe(false);
|
||||
});
|
||||
|
||||
test('show_totals renders in the customize tab atop visual formatting', () => {
|
||||
const visualFormatting = config.controlPanelSections.find(
|
||||
section => section?.label === 'Visual formatting',
|
||||
);
|
||||
expect(visualFormatting).toBeDefined();
|
||||
|
||||
const [firstRow] = visualFormatting!.controlSetRows;
|
||||
const firstControl = firstRow[0] as CustomControlItem;
|
||||
expect(firstControl.name).toBe('show_totals');
|
||||
// renderTrigger keeps the whole section classified into the customize tab
|
||||
// and must not regress; without it the section moves to the data tab.
|
||||
expect(firstControl.config.renderTrigger).toBe(true);
|
||||
// No visibility gate: the summary checkbox must render in raw records
|
||||
// mode as well as aggregate mode.
|
||||
expect(firstControl.config.visibility).toBeUndefined();
|
||||
});
|
||||
|
||||
test('every Visual formatting control is a renderTrigger', () => {
|
||||
// A non-renderTrigger control in this section would silently drag the
|
||||
// whole section's classification from the customize tab to the data tab.
|
||||
const visualFormatting = config.controlPanelSections.find(
|
||||
section => section?.label === 'Visual formatting',
|
||||
);
|
||||
expect(visualFormatting).toBeDefined();
|
||||
|
||||
const controls = visualFormatting!.controlSetRows
|
||||
.flat()
|
||||
.filter(isCustomControlItem);
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
|
||||
controls.forEach(control => {
|
||||
expect(control.config.renderTrigger).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"geojson": "^0.5.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"d3-array": "^3.2.4",
|
||||
"lodash": "^4.18.1",
|
||||
"zod": "^4.4.3",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"currencyformatter.js": "^1.0.5",
|
||||
"handlebars-group-by": "^1.0.1",
|
||||
"just-handlebars-helpers": "^1.0.19",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@superset-ui/chart-controls": "*",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"xss": "^1.0.15",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"d3-cloud": "^1.2.9",
|
||||
"d3-scale": "^4.0.2",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
|
||||
@@ -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",
|
||||
@@ -54,7 +54,7 @@
|
||||
"underscore": "^1.13.7",
|
||||
"urijs": "^1.19.11",
|
||||
"xss": "^1.0.15",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mapbox__geojson-extent": "^1.0.3",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user