Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f16c59bd | ||
|
|
652f61ee28 |
@@ -34,7 +34,7 @@
|
||||
|
||||
**/*.geojson @villebro @rusackas
|
||||
**/*.ipynb @villebro @rusackas
|
||||
/superset-frontend/plugins/plugin-chart-country-map/ @villebro @rusackas
|
||||
/superset-frontend/plugins/legacy-plugin-chart-country-map/ @villebro @rusackas
|
||||
|
||||
# Notify translation maintainers of changes to translations
|
||||
|
||||
|
||||
@@ -47,13 +47,6 @@ updates:
|
||||
# as a single manual upgrade anyway. TODO: remove when Babel 8 support is viable.
|
||||
- dependency-name: "@babel/*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# v2.0.0 renamed ZSTDDecompress to decompress and made it async, breaking
|
||||
# the webpack dev proxy (see #38662, #39138, #39139). Dependabot reopened
|
||||
# the same bump in #39369 after the first revert, so pin it here instead
|
||||
# of relying on a package.json comment (package.json is JSON and can't
|
||||
# hold comments). Remove this once the proxy code is updated to await
|
||||
# the async decompress() API.
|
||||
- dependency-name: "simple-zstd"
|
||||
directory: "/superset-frontend/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
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@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
node-version-file: "./docs/.nvmrc"
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "21"
|
||||
|
||||
@@ -138,7 +138,6 @@ PROJECT.md
|
||||
.aider*
|
||||
.claude_rc*
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
.env.local
|
||||
oxc-custom-build/
|
||||
*.code-workspace
|
||||
|
||||
@@ -2,29 +2,22 @@
|
||||
|
||||
Apache Superset is a data visualization platform with Flask/Python backend and React/TypeScript frontend.
|
||||
|
||||
## Run Pre-commit Before Pushing
|
||||
## ⚠️ CRITICAL: Always Run Pre-commit Before Pushing
|
||||
|
||||
Always run pre-commit against the files changed by the current branch before
|
||||
pushing. This matches CI and keeps unrelated failures already present on
|
||||
`master` from blocking otherwise independent work.
|
||||
**ALWAYS run `pre-commit run --all-files` before pushing commits.** CI will fail if pre-commit checks don't pass. This is non-negotiable.
|
||||
|
||||
```bash
|
||||
# Stage your changes first
|
||||
git add .
|
||||
|
||||
# Run pre-commit on staged files
|
||||
pre-commit run
|
||||
# Run pre-commit on all files
|
||||
pre-commit run --all-files
|
||||
|
||||
# If there are auto-fixes, stage them and commit
|
||||
git add .
|
||||
git commit --amend # or new commit
|
||||
```
|
||||
|
||||
Use `pre-commit run --all-files` when auditing or repairing the repository-wide
|
||||
baseline. If that check finds failures in files untouched by the current branch,
|
||||
fix them in a separate branch rather than adding unrelated changes to the
|
||||
current pull request.
|
||||
|
||||
Common pre-commit failures:
|
||||
- **Formatting** - black, oxfmt, eslint will auto-fix
|
||||
- **Type errors** - mypy failures need manual fixes
|
||||
|
||||
@@ -24,34 +24,6 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Soft delete is on by default, and purging is live
|
||||
|
||||
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
|
||||
dashboard, chart, or dataset archives it rather than removing it. Archived
|
||||
objects are hidden from normal listings, recoverable from **Recently Archived**,
|
||||
and permanently removed once the retention window elapses.
|
||||
`SOFT_DELETE_PURGE_DRY_RUN` also flips to `False`, so the nightly
|
||||
`deletion_retention.purge_soft_deleted` task deletes for real instead of only
|
||||
logging `would_purge` counts.
|
||||
|
||||
**What operators should do before upgrading:**
|
||||
|
||||
- **Size the first live purge.** The first real run removes every entity that
|
||||
aged past `SOFT_DELETE_RETENTION_DAYS` (default 30) since soft delete began
|
||||
capturing, which on a busy deployment can be a large batch in one window. To
|
||||
see the size first, set `SOFT_DELETE_PURGE_DRY_RUN = True`, read the
|
||||
`would_purge` counts from one nightly run, then set it back.
|
||||
- **Check a replaced `CELERY_CONFIG`.** A deployment that redefines it rather
|
||||
than inheriting must carry both `superset.tasks.deletion_retention` in
|
||||
`imports` and the `deletion_retention.purge_soft_deleted` beat entry;
|
||||
a startup warning now names whichever is absent.
|
||||
|
||||
**Both switches are retained.** `SOFT_DELETE = False` restores hard-delete
|
||||
behaviour and `SOFT_DELETE_PURGE_DRY_RUN = True` suspends purging, at any time.
|
||||
One caveat on turning soft delete back off: objects archived while it was on are
|
||||
**resurrected** into normal listings, since the rows were never removed — an
|
||||
emergency stop rather than a clean rollback.
|
||||
|
||||
### Scheduled report execution now enforces one application deadline
|
||||
|
||||
Scheduled report (not alert) executions are now governed by a single
|
||||
@@ -289,10 +261,6 @@ are the intended model going forward; deprecating and removing implicit viewersh
|
||||
in a later major version.
|
||||
|
||||
- [41044](https://github.com/apache/superset/issues/41044): Removes the deprecated `AVOID_COLORS_COLLISION` feature flag (it defaulted to `True`). Color-collision avoidance is now permanently enabled; any config override setting it to `False` is ignored.
|
||||
- [41714](https://github.com/apache/superset/pull/41714): **Breaking — the legacy `explore_json` chart-data pipeline is removed** at its long-declared `5.0.0` EOL. The `/superset/explore_json/` and `/superset/explore_json/data/<cache_key>` endpoints, `superset/viz.py`, the `Slice.viz` property, the `get_viz` factory, the `load_explore_json_into_cache` celery task and the `viz=` overload of `security_manager.raise_for_access` are gone. Anything importing `superset.viz` must migrate to the QueryContext / `pandas_postprocessing` pipeline behind `/api/v1/chart/data`. All 15 remaining legacy charts were migrated first: most keep their `viz_type` and renderer (no action needed for saved charts), while saved nvd3 Bubble charts are auto-migrated to the ECharts Bubble Chart (`bubble_v2`) and saved "Time-series Percent Change" (`compare`) charts to the ECharts Line Chart, which restores the nvd3 renderer's interactive percent re-basing via a draggable baseline. The deck.gl Multiple Layers chart now fetches its layers entirely client-side, refitting the viewport as each layer's data arrives, and caps the number of sub-slices fanned out per chart at `DECK_MULTI_MAX_SLICES` (default 50, configurable); dashboard filter badges no longer aggregate child-layer filter metadata.
|
||||
|
||||
- [41714](https://github.com/apache/superset/pull/41714): Charts migrated in place keep a `NULL` saved query context until they are next opened in Explore (which regenerates it automatically) or re-saved. Until then, cache warm-up and annotation layers referencing such a chart report an actionable error rather than warming/rendering; opening the chart once resolves it.
|
||||
- [41714](https://github.com/apache/superset/pull/41714): **Breaking for third-party viz plugins** — the `useLegacyApi` field of `ChartMetadata` in `@superset-ui/core` is removed. Plugins that set it must provide a `buildQuery` and consume `/api/v1/chart/data`. The migrated first-party packages also drop their `legacy-` prefix: `@superset-ui/legacy-plugin-chart-{calendar,chord,country-map,horizon,paired-t-test,parallel-coordinates,partition,rose,world-map}` → `@superset-ui/plugin-chart-*`, and `@superset-ui/legacy-preset-chart-nvd3` → `@superset-ui/preset-chart-nvd3`. The `can_explore_json` permission is no longer created or granted; custom roles referencing it should switch to the `can_read` permissions on `Chart`.
|
||||
|
||||
- [41813](https://github.com/apache/superset/pull/41813): `redis` (the Python client, `redis-py`) is bumped from 5.3.1 to 8.0.1. redis-py 8 changes several connection defaults; Superset's own Redis-backed features (`GLOBAL_ASYNC_QUERIES_CACHE_BACKEND`, `DISTRIBUTED_COORDINATION_CONFIG`, and the MCP Redis store) explicitly pin the pre-upgrade behavior so this bump is a no-op for them: the wire protocol stays RESP2 (not the new RESP3 default, which requires Redis/Sentinel 6+ to speak `HELLO`) and there is still no socket timeout by default (redis-py 8 defaults to 5s, which could otherwise newly time out large cached payloads or slow networks). The no-timeout default can now be overridden via two new config keys, `CACHE_REDIS_SOCKET_TIMEOUT` / `CACHE_REDIS_SOCKET_CONNECT_TIMEOUT`, on any `CacheConfig` dict using `CACHE_TYPE: RedisCache` or `RedisSentinelCache`. Separately, redis-py 6+ changed the default for `ssl_check_hostname` from `False` to `True` for SSL connections using `ssl_cert_reqs="required"` (the default) — this is a security improvement, so it has **not** been reverted; deployments with `CACHE_REDIS_SSL=True` whose certificates lack a hostname matching the connection address should set `CACHE_REDIS_SSL_CERT_REQS="none"` (disables cert verification entirely, matching hostname-check bypass) or replace the certificate. General-purpose cache/results backends configured via `CACHE_CONFIG` / `DATA_CACHE_CONFIG` / `RESULTS_BACKEND` with `CACHE_TYPE: RedisCache` go through `flask-caching`'s own Redis backend (outside Superset's code) and are subject to the same new defaults; pass `socket_timeout` / `protocol` via `CACHE_OPTIONS` there if needed. Celery broker and result-backend connections (built by `kombu`, also outside Superset's code) keep their no-socket-timeout behavior (`kombu` passes `socket_timeout=None` explicitly) but do **not** pin the wire protocol, so they follow redis-py's RESP3 default — which requires a Redis server new enough to speak `HELLO` (Redis 6+). Deployments using a pre-6.0 Redis server (EOL) as a Celery broker should upgrade the server before taking this bump.
|
||||
|
||||
@@ -312,7 +280,7 @@ in a later major version.
|
||||
|
||||
- **`SqlaTable.sql_url` query-string format.** `SqlaTable.sql_url` now URL-encodes `table_name` and joins it as a query parameter rather than concatenating a second `?`. Previously, with `Database.sql_url` returning `/sqllab/?dbid=<id>`, the concatenation produced `/sqllab/?dbid=<id>?table_name=<raw>` — a malformed second `?` that broke the query parser. External code that parsed the legacy `<base>?table_name=<raw>` shape now sees properly percent-encoded values (e.g. `/` → `%2F`, ` ` → `+` or `%20`); decode with `urllib.parse.parse_qsl`.
|
||||
|
||||
- **New config flag `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default `False`).** Share/permalink URLs now substitute `window.location.origin` for the backend-supplied origin so a proxied or subdirectory-deployed Superset never hands the user an unreachable internal hostname. Operators whose reverse proxy correctly forwards `X-Forwarded-Host` _and_ who want permalinks to carry the backend's literal origin can opt out by setting `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = True` in `superset_config.py`. Default `False` (rewrite is on); flipping the default would regress the dominant proxied/subdir deployment to an unreachable host.
|
||||
- **New config flag `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default `False`).** Share/permalink URLs now substitute `window.location.origin` for the backend-supplied origin so a proxied or subdirectory-deployed Superset never hands the user an unreachable internal hostname. Operators whose reverse proxy correctly forwards `X-Forwarded-Host` *and* who want permalinks to carry the backend's literal origin can opt out by setting `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = True` in `superset_config.py`. Default `False` (rewrite is on); flipping the default would regress the dominant proxied/subdir deployment to an unreachable host.
|
||||
|
||||
- [41651](https://github.com/apache/superset/pull/41651): **New do-not-translate standard for translation catalogs.** Strings that must stay identical to the source — icon names (e.g. `bolt`), enum/option values (`step-after`), SQL keywords, API field names (`error_message`), code constants, and example placeholders — are now marked with a `#. do-not-translate` extracted comment. The list lives in the `superset/translations/do-not-translate.txt` registry; `scripts/translations/apply_do_not_translate.py` stamps the marker onto `messages.pot` during `babel_update.sh`, and `pybabel update` propagates it to every `.po`, so the status is consistent across all languages. The AI backfill (`backfill_po.py`) and translators leave these entries untranslated (source fallback). The legacy per-catalog convention (a `# Не переводить` translator comment in the `ru` catalog) is still honored for back-compat but is superseded by this standard; contributors adding new machine-read strings should add the msgid to the registry rather than annotating individual catalogs.
|
||||
|
||||
@@ -349,7 +317,7 @@ Theme tokens are unaffected — antd 6 removed none of the tokens Superset expos
|
||||
|
||||
### Guest-token RLS rules reject unknown fields
|
||||
|
||||
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a _global_ rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
|
||||
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
|
||||
|
||||
### MCP service requires `MCP_JWT_AUDIENCE` when JWT auth is enabled
|
||||
|
||||
@@ -447,7 +415,6 @@ ALTER TABLE tagged_object DROP CONSTRAINT <constraint_name>;
|
||||
-- MySQL: find names via `SHOW CREATE TABLE tagged_object;`
|
||||
ALTER TABLE tagged_object DROP FOREIGN KEY <constraint_name>;
|
||||
```
|
||||
|
||||
### Entity version-history infrastructure (gated off by default)
|
||||
|
||||
Introduces the schema and SQLAlchemy-Continuum wiring that captures version history for charts, dashboards, and datasets, plus read-only `GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/` endpoints. This ships **inert**: a new config flag `ENABLE_VERSIONING_CAPTURE` defaults to `False`, so no save writes any version rows and the endpoints return empty. It is an operational kill-switch (a release toggle that becomes a permanent ops switch), not a feature flag — set it to `True` to enable capture once validated. The migration is additive; existing entity `PUT` responses gain `old_version_uuid` / `new_version_uuid` body fields and an `ETag` header (both null/absent when capture is off).
|
||||
@@ -464,12 +431,12 @@ These are behavior changes that take effect on upgrade regardless of `ENABLE_VER
|
||||
|
||||
A read-only companion to the version-history endpoints: each entity type gains a `GET /api/v1/{chart,dashboard,dataset}/<uuid>/activity/` endpoint returning a chronological, access-filtered stream of edits — the entity's own edits plus, for charts and dashboards, transitive edits to related entities during their association windows. Datasets have no related layer in V2, so `include=related` returns an empty stream for a dataset and `include=all` reduces to the dataset's own edits.
|
||||
|
||||
| Param | Type | Default | Purpose |
|
||||
| -------------------- | ---------------------------- | ---------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `since` / `until` | ISO 8601 | — | Bound `issued_at` |
|
||||
| `include` | `self` \| `related` \| `all` | `all` | Own edits, related edits, or both |
|
||||
| `q` | string | — | Case-insensitive search over the full history, applied before pagination (so `count` reflects matches) |
|
||||
| `page` / `page_size` | integer | `0` / `25` | Pagination (`page_size` clamped to 200) |
|
||||
| Param | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `since` / `until` | ISO 8601 | — | Bound `issued_at` |
|
||||
| `include` | `self` \| `related` \| `all` | `all` | Own edits, related edits, or both |
|
||||
| `q` | string | — | Case-insensitive search over the full history, applied before pagination (so `count` reflects matches) |
|
||||
| `page` / `page_size` | integer | `0` / `25` | Pagination (`page_size` clamped to 200) |
|
||||
|
||||
Authorization reuses the resource's `can_read` permission and per-object `raise_for_access`; related-entity rows are visibility-filtered to what the caller may see. The stream is empty unless version capture is on (`ENABLE_VERSIONING_CAPTURE`).
|
||||
|
||||
@@ -487,7 +454,7 @@ The task ships in the default `CeleryConfig` (both the `superset.tasks.version_h
|
||||
|
||||
Soft-deleted dashboards, charts, and datasets are now permanently removed after a retention window (default 30 days; `SOFT_DELETE_RETENTION_DAYS`, `0` disables; settable per workspace at runtime via the `deletion-retention set-window` CLI, which takes precedence). The `deletion_retention.purge_soft_deleted` Celery beat task runs daily and removes each aged-out entity together with its M:N join rows, owned children, datasource permission, and version-history shadow rows. After purge an entity is **unrecoverable** — its detail and `/restore` endpoints return 404 and its version history is gone.
|
||||
|
||||
Purging is **live by default** (`SOFT_DELETE_PURGE_DRY_RUN=False`), so the retention promise above is real on a stock deployment. Set it to `True` to have the task log `would_purge` counts and delete nothing — the lever is retained, so an operator can return to dry-run at any time. Note `would_purge` is an **upper bound** — it counts every entity past the retention window without evaluating deletion blockers, so a real run may purge fewer (entities referenced by report schedules or set as a user's welcome dashboard are blocked and reported separately). The task only acts while the `SOFT_DELETE` rollout flag is on; it now ships on by default.
|
||||
The introducing release **defaults to dry-run** (`SOFT_DELETE_PURGE_DRY_RUN=True`): the task logs `would_purge` counts but deletes nothing, so operators can validate against production before activating real purging by setting it to `False`. Note `would_purge` is an **upper bound** — it counts every entity past the retention window without evaluating deletion blockers, so a real run may purge fewer (entities referenced by report schedules or set as a user's welcome dashboard are blocked and reported separately). The task only acts while the temporary `SOFT_DELETE` rollout flag is on.
|
||||
|
||||
Deployments that replace the default `CELERY_CONFIG` must ensure workers register `superset.tasks.deletion_retention` and schedule the `deletion_retention.purge_soft_deleted` task themselves. The shipped Docker development config uses `imports` and includes both entries. While `SOFT_DELETE` is statically enabled, a missing beat entry logs a startup warning; when the override explicitly defines `imports`, a missing purge module is also reported.
|
||||
|
||||
@@ -553,7 +520,6 @@ Operators can tune or disable the policy via config:
|
||||
### Data uploads bounded by UPLOAD_MAX_FILE_SIZE_BYTES
|
||||
|
||||
Single data-file uploads (CSV, Excel, columnar) are now bounded by the `UPLOAD_MAX_FILE_SIZE_BYTES` config option, which defaults to `100 * 1024 * 1024` (100 MB). Files larger than this are rejected with a `413` before their contents are buffered into memory. Set `UPLOAD_MAX_FILE_SIZE_BYTES = None` to disable the check and restore unbounded uploads.
|
||||
|
||||
### Currency symbol position follows the locale when unset
|
||||
|
||||
When a chart's currency control leaves the **Prefix or suffix** field empty, the currency symbol position is now derived from the deployment locale's own convention via `Intl.NumberFormat` instead of always defaulting to a suffix. For example, under the default `en-US` locale `USD`, `GBP`, and `EUR` render as a prefix (`$ 1,000`), while eurozone locales such as `fr-FR` render `EUR` as a suffix (`1 000 €`). An explicit Prefix/Suffix selection is always honored and is unaffected.
|
||||
@@ -679,7 +645,7 @@ SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes"
|
||||
```bash
|
||||
superset re-encrypt-secrets --engine aes-gcm
|
||||
```
|
||||
A live instance keeps writing _new_ secrets as AES-CBC during the window between step 2 and the restart in step 4; this second pass sweeps those up (it is idempotent, so already-migrated values are skipped).
|
||||
A live instance keeps writing *new* secrets as AES-CBC during the window between step 2 and the restart in step 4; this second pass sweeps those up (it is idempotent, so already-migrated values are skipped).
|
||||
|
||||
Schedule the cutover in a quiet window. Runtime reads use only the single configured engine, so in a multi-worker deployment there is an unavoidable brief decrypt-outage between the migration commit and the last worker restarting with the new config — each migrator run is transactional, but the fleet-wide cutover is not zero-downtime.
|
||||
|
||||
@@ -707,11 +673,11 @@ With the flag enabled: `DELETE /api/v1/dataset/<id>` no longer hard-deletes the
|
||||
|
||||
**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_tables_deleted_at`) to the `tables` table. The column add is instant; the index build runs inline (no `CONCURRENTLY`) and may briefly block writes on the `tables` 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). Production deployments with many thousands of datasets should run this migration during a maintenance window.
|
||||
|
||||
**Rollback note:** if the application code is rolled back after datasets 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 datasets silently become live, active datasets 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.
|
||||
**Rollback note:** if the application code is rolled back after datasets 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 datasets silently become live, active datasets 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.
|
||||
|
||||
**SQL Lab / dataset-creation flows:** creating a dataset over a table whose dataset sits in the trash is refused. The SQL Lab "save as dataset" flow (`get_or_create_dataset`) and file uploads return a **422 naming the hidden twin and the restore endpoint**; the plain create, update, and duplicate paths currently fail with the generic "already exists" 422. In all cases the remediation is the same: restore the hidden dataset (or use a different table name). Perm-string maintenance also covers hidden rows: renaming a database rewrites `perm`/`schema_perm`/`catalog_perm` on soft-deleted datasets and their charts, so a later restore does not resurrect stale permission strings.
|
||||
|
||||
**Importer behavior:** importing a dataset YAML whose UUID matches an existing **soft-deleted** dataset 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 dataset 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 dataset's exact UUID is an explicit request to bring it back. The restore preserves the original PK, the chart back-reference, `table_columns`, and `sql_metrics`. Non-editors get `ImportFailedError`. Callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
**Importer behavior:** importing a dataset YAML whose UUID matches an existing **soft-deleted** dataset 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 dataset 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 dataset's exact UUID is an explicit request to bring it back. The restore preserves the original PK, the chart back-reference, `table_columns`, and `sql_metrics`. Non-editors get `ImportFailedError`. Callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
|
||||
**Uniqueness-validation changes that apply regardless of the feature flag:** two dataset uniqueness checks were tightened alongside this work and are active even with `SOFT_DELETE` off. (1) Create/update uniqueness treats a dataset whose `catalog` is `NULL` as belonging to the database's default catalog, so a legacy twin pair (`catalog=NULL` vs. `catalog=<default>`, same database/schema/name) that older versions allowed now fails validation with "already exists" when either row is edited — resolve by renaming or removing one of the twins. (2) Duplicating a dataset now checks name collisions scoped to the target (database, catalog, schema) instead of globally by name alone: duplicates into other databases that were previously blocked are now allowed.
|
||||
|
||||
@@ -731,9 +697,9 @@ With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the ch
|
||||
|
||||
**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.
|
||||
**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-editors get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
**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-editors 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.
|
||||
|
||||
@@ -757,7 +723,7 @@ The partial-index replacement is dialect-dependent: PostgreSQL uses a native `WH
|
||||
|
||||
**Slug semantics:** on PostgreSQL and MySQL 8.0.13+, the slug of a soft-deleted dashboard is **free for reuse**. A new active dashboard can claim it immediately. Restoring a soft-deleted dashboard whose slug has since been claimed returns **422 with a clean error** (`DashboardSlugConflictError`) — rename one of the dashboards and retry; the restore is not silently rejected by a database-level constraint violation.
|
||||
|
||||
**Importer behavior:** importing a dashboard YAML whose UUID matches an existing **soft-deleted** dashboard 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 dashboard imported without `overwrite=true` is returned unchanged (the import never mutates it), 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 dashboard's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all pre-deletion relationship rows (`dashboard_slices` junctions, editor/viewer subjects, tags). Callers whose imports must never mutate existing state should treat bundles that may contain previously deleted UUIDs accordingly. The operation is permission-gated: it requires `can_write` and editorship of the deleted row (or admin) — non-editors get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
**Importer behavior:** importing a dashboard YAML whose UUID matches an existing **soft-deleted** dashboard 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 dashboard imported without `overwrite=true` is returned unchanged (the import never mutates it), 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 dashboard's exact UUID is an explicit request to bring it back. The restore preserves the original PK and all pre-deletion relationship rows (`dashboard_slices` junctions, editor/viewer subjects, tags). Callers whose imports must never mutate existing state should treat bundles that may contain previously deleted UUIDs accordingly. The operation is permission-gated: it requires `can_write` and editorship of the deleted row (or admin) — non-editors get `ImportFailedError`, and callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
|
||||
|
||||
### Engine spec capability flag: `supports_offset`
|
||||
|
||||
@@ -769,10 +735,10 @@ A new `BaseEngineSpec.supports_offset` attribute (default `True`) indicates whet
|
||||
|
||||
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
|
||||
|
||||
| Permission | Controls |
|
||||
| -------------------- | ---------------------------- |
|
||||
| `can_export_data` | CSV, Excel, JSON exports |
|
||||
| `can_export_image` | Screenshot/PDF exports |
|
||||
| Permission | Controls |
|
||||
|---|---|
|
||||
| `can_export_data` | CSV, Excel, JSON exports |
|
||||
| `can_export_image` | Screenshot/PDF exports |
|
||||
| `can_copy_clipboard` | Copy-to-clipboard operations |
|
||||
|
||||
When the feature flag is enabled, these permissions are enforced on both the frontend (disabled buttons with tooltips) and backend (403 responses from API endpoints). When disabled, legacy `can_csv` behavior is preserved.
|
||||
@@ -816,17 +782,14 @@ The Kenya country map has been updated to reflect the 47 counties established un
|
||||
MCP (Model Context Protocol) tools now include enhanced observability instrumentation for monitoring and debugging:
|
||||
|
||||
**Two-layer instrumentation:**
|
||||
|
||||
1. **Middleware layer** (`LoggingMiddleware`): Automatically logs all MCP tool calls with `duration_ms` and `success` status in the audit log (Action Log UI, logs table)
|
||||
2. **Sub-operation tracking**: All 19 MCP tools include granular `event_logger.log_context()` blocks for tracking individual operations like validation, database writes, and query execution
|
||||
|
||||
**Action naming convention:**
|
||||
|
||||
- Tool-level logs: `mcp_tool_call` (via middleware)
|
||||
- Sub-operation logs: `mcp.{tool_name}.{operation}` (e.g., `mcp.generate_chart.validation`, `mcp.execute_sql.query_execution`)
|
||||
|
||||
**Querying MCP logs:**
|
||||
|
||||
```sql
|
||||
-- Top slowest MCP operations
|
||||
SELECT action, COUNT(*) as calls, AVG(duration_ms) as avg_ms
|
||||
@@ -861,7 +824,6 @@ A new `DISTRIBUTED_COORDINATION_CONFIG` configuration provides a unified Redis-b
|
||||
The distributed coordination is used by the Global Task Framework (GTF) for abort notifications and task completion signaling, and will eventually replace `GLOBAL_ASYNC_QUERIES_CACHE_BACKEND` as the standard signaling backend. Configuring this is recommended for Redis enabled production deployments.
|
||||
|
||||
Example configuration in `superset_config.py`:
|
||||
|
||||
```python
|
||||
DISTRIBUTED_COORDINATION_CONFIG = {
|
||||
"CACHE_TYPE": "RedisCache",
|
||||
@@ -876,11 +838,9 @@ See `superset/config.py` for complete configuration options.
|
||||
### WebSocket config for GAQ with Docker
|
||||
|
||||
[35896](https://github.com/apache/superset/pull/35896) and [37624](https://github.com/apache/superset/pull/37624) updated documentation on how to run and configure Superset with Docker. Specifically for the WebSocket configuration, a new `docker/superset-websocket/config.example.json` was added to the repo, so that users could copy it to create a `docker/superset-websocket/config.json` file. The existing `docker/superset-websocket/config.json` was removed and git-ignored, so if you're using GAQ / WebSocket make sure to:
|
||||
|
||||
- Stash/backup your existing `config.json` file, to re-apply it after (will get git-ignored going forward)
|
||||
- Update the `volumes` configuration for the `superset-websocket` service in your `docker-compose.override.yml` file, to include the `docker/superset-websocket/config.json` file. For example:
|
||||
|
||||
```yaml
|
||||
``` yaml
|
||||
services:
|
||||
superset-websocket:
|
||||
volumes:
|
||||
@@ -893,9 +853,7 @@ services:
|
||||
### Example Data Loading Improvements
|
||||
|
||||
#### New Directory Structure
|
||||
|
||||
Examples are now organized by name with data and configs co-located:
|
||||
|
||||
```
|
||||
superset/examples/
|
||||
├── _shared/ # Shared database & metadata configs
|
||||
@@ -908,14 +866,12 @@ superset/examples/
|
||||
```
|
||||
|
||||
#### Simplified Parquet-based Loading
|
||||
|
||||
- Auto-discovery: create `superset/examples/my_dataset/data.parquet` to add a new example
|
||||
- Parquet is an Apache project format: compressed (~27% smaller), self-describing schema
|
||||
- YAML configs define datasets, charts, and dashboards declaratively
|
||||
- Removed Python-based data generation from individual example files
|
||||
|
||||
#### Test Data Reorganization
|
||||
|
||||
- Moved `big_data.py` to `superset/cli/test_loaders.py` - better reflects its purpose as a test utility
|
||||
- Fixed inverted logic for `--load-test-data` flag (now correctly includes .test.yaml files when flag is set)
|
||||
- Clarified CLI flags:
|
||||
@@ -925,7 +881,6 @@ superset/examples/
|
||||
- `--load-big-data` / `-b`: Generate synthetic stress-test data
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- Fixed numpy array serialization for PostgreSQL (converts complex types to JSON strings)
|
||||
- Fixed KeyError for `allow_csv_upload` field in database configs (now optional with default)
|
||||
- Fixed test data loading logic that was incorrectly filtering files
|
||||
@@ -935,7 +890,6 @@ superset/examples/
|
||||
The MCP (Model Context Protocol) service enables AI assistants and automation tools to interact programmatically with Superset.
|
||||
|
||||
#### New Features
|
||||
|
||||
- MCP service infrastructure with FastMCP framework
|
||||
- Tools for dashboards, charts, datasets, SQL Lab, and instance metadata
|
||||
- Optional dependency: install with `pip install apache-superset[fastmcp]`
|
||||
@@ -945,7 +899,6 @@ The MCP (Model Context Protocol) service enables AI assistants and automation to
|
||||
#### New Configuration Options
|
||||
|
||||
**Development** (single-user, local testing):
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
MCP_DEV_USERNAME = "admin" # User for MCP authentication
|
||||
@@ -954,7 +907,6 @@ MCP_SERVICE_PORT = 5008
|
||||
```
|
||||
|
||||
**Production** (JWT-based, multi-user):
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
MCP_AUTH_ENABLED = True
|
||||
@@ -1000,14 +952,12 @@ superset mcp run --port 5008 --use-factory-config
|
||||
The MCP service runs as a **separate process** from the Superset web server.
|
||||
|
||||
**Important**:
|
||||
|
||||
- Requires same Python environment and configuration as Superset
|
||||
- Shares database connections with main Superset app
|
||||
- Can be scaled independently from web server
|
||||
- Requires `fastmcp` package (optional dependency)
|
||||
|
||||
**Installation**:
|
||||
|
||||
```bash
|
||||
# Install with MCP support
|
||||
pip install apache-superset[fastmcp]
|
||||
@@ -1021,7 +971,6 @@ Use systemd, supervisord, or Kubernetes to manage the MCP service process.
|
||||
See `superset/mcp_service/PRODUCTION.md` for deployment guides.
|
||||
|
||||
**Security**:
|
||||
|
||||
- Development: Uses `MCP_DEV_USERNAME` for single-user access
|
||||
- Production: **MUST** configure JWT authentication
|
||||
- See `superset/mcp_service/SECURITY.md` for details
|
||||
@@ -1041,10 +990,8 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
|
||||
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- [37370](https://github.com/apache/superset/pull/37370): The `APP_NAME` configuration variable no longer controls the browser window/tab title or other frontend branding. Application names should now be configured using the theme system with the `brandAppName` token. The `APP_NAME` config is still used for backend contexts (MCP service, logs, etc.) and serves as a fallback if `brandAppName` is not set.
|
||||
- **Migration:**
|
||||
|
||||
```python
|
||||
# Before (Superset 5.x)
|
||||
APP_NAME = "My Custom App"
|
||||
@@ -1088,16 +1035,16 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
|
||||
|
||||
Eight M:N association tables move from a synthetic `id INTEGER PRIMARY KEY` to a composite `PRIMARY KEY (fk1, fk2)` on their two foreign-key columns. The surrogate `id` is dropped, and the redundant `UNIQUE (fk1, fk2)` on the two tables that carried one is removed (now subsumed by the PK).
|
||||
|
||||
| Table | Composite PK |
|
||||
| ---------------------- | ------------------------------- |
|
||||
| `dashboard_roles` | `(dashboard_id, role_id)` |
|
||||
| `dashboard_slices` | `(dashboard_id, slice_id)` |
|
||||
| `dashboard_user` | `(user_id, dashboard_id)` |
|
||||
| Table | Composite PK |
|
||||
|---|---|
|
||||
| `dashboard_roles` | `(dashboard_id, role_id)` |
|
||||
| `dashboard_slices` | `(dashboard_id, slice_id)` |
|
||||
| `dashboard_user` | `(user_id, dashboard_id)` |
|
||||
| `report_schedule_user` | `(user_id, report_schedule_id)` |
|
||||
| `rls_filter_roles` | `(role_id, rls_filter_id)` |
|
||||
| `rls_filter_tables` | `(table_id, rls_filter_id)` |
|
||||
| `slice_user` | `(user_id, slice_id)` |
|
||||
| `sqlatable_user` | `(user_id, table_id)` |
|
||||
| `rls_filter_roles` | `(role_id, rls_filter_id)` |
|
||||
| `rls_filter_tables` | `(table_id, rls_filter_id)` |
|
||||
| `slice_user` | `(user_id, slice_id)` |
|
||||
| `sqlatable_user` | `(user_id, table_id)` |
|
||||
|
||||
**Before upgrading:**
|
||||
|
||||
@@ -1108,7 +1055,6 @@ Eight M:N association tables move from a synthetic `id INTEGER PRIMARY KEY` to a
|
||||
For large `dashboard_slices` / `report_schedule_user` tables, see the operator runbook in [#39859](https://github.com/apache/superset/pull/39859) — pre-flight inventory queries, per-dialect lock-window sizing, and the duplicate / NULL-FK roll-up — to plan the maintenance window.
|
||||
|
||||
## 6.0.0
|
||||
|
||||
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
|
||||
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
|
||||
- [34782](https://github.com/apache/superset/pull/34782): Dataset exports now include the dataset ID in their file name (similar to charts and dashboards). If managing assets as code, make sure to rename existing dataset YAMLs to include the ID (and avoid duplicated files).
|
||||
@@ -1117,8 +1063,8 @@ For large `dashboard_slices` / `report_schedule_user` tables, see the operator r
|
||||
- Change any hex color values to one of: `"success"`, `"processing"`, `"error"`, `"warning"`, `"default"`
|
||||
- Custom colors are no longer supported to maintain consistency with Ant Design components
|
||||
- [34561](https://github.com/apache/superset/pull/34561) Added tiled screenshot functionality for Playwright-based reports to handle large dashboards more efficiently. When enabled (default: `SCREENSHOT_TILED_ENABLED = True`), dashboards with 20+ charts or height exceeding 5000px will be captured using multiple viewport-sized tiles and combined into a single image. This improves report generation performance and reliability for large dashboards.
|
||||
Note: Pillow is now a required dependency (previously optional) to support image processing for tiled screenshots.
|
||||
`thumbnails` optional dependency is now deprecated and will be removed in the next major release (7.0).
|
||||
Note: Pillow is now a required dependency (previously optional) to support image processing for tiled screenshots.
|
||||
`thumbnails` optional dependency is now deprecated and will be removed in the next major release (7.0).
|
||||
- [33084](https://github.com/apache/superset/pull/33084) The DISALLOWED_SQL_FUNCTIONS configuration now includes additional potentially sensitive database functions across PostgreSQL, MySQL, SQLite, MS SQL Server, and ClickHouse. Existing queries using these functions may now be blocked. Review your SQL Lab queries and dashboards if you encounter "disallowed function" errors after upgrading
|
||||
- [34235](https://github.com/apache/superset/pull/34235) CSV exports now use `utf-8-sig` encoding by default to include a UTF-8 BOM, improving compatibility with Excel.
|
||||
- [34258](https://github.com/apache/superset/pull/34258) changing the default in Dockerfile to INCLUDE_CHROMIUM="false" (from "true") in the past. This ensures the `lean` layer is lean by default, and people can opt-in to the `chromium` layer by setting the build arg `INCLUDE_CHROMIUM=true`. This is a breaking change for anyone using the `lean` layer, as it will no longer include Chromium by default.
|
||||
|
||||
@@ -54,8 +54,8 @@ celery --app=superset.tasks.celery_app:app beat
|
||||
|
||||
To setup a result backend, you need to pass an instance of a derivative of `BaseCache` (`from
|
||||
flask_caching.backends.base import BaseCache`) to the RESULTS_BACKEND configuration key in your
|
||||
superset_config.py. You can use Memcached, Redis, S3, MinIO, memory
|
||||
or the file system (in a single server-type setup or for testing), or to write your own
|
||||
superset_config.py. You can use Memcached, Redis, S3 (https://pypi.python.org/pypi/s3werkzeugcache),
|
||||
memory or the file system (in a single server-type setup or for testing), or to write your own
|
||||
caching interface. Your `superset_config.py` may look something like:
|
||||
|
||||
```python
|
||||
@@ -89,12 +89,6 @@ issues arise. Please clear your existing results cache store when upgrading an e
|
||||
- SQL Lab will _only run your queries asynchronously if_ you enable **Asynchronous Query Execution**
|
||||
in your database settings (Sources > Databases > Edit record).
|
||||
|
||||
- In order to use dedicated results backend, additional python libraries must be installed. These libraries can be installed using pip.
|
||||
- [redis-py](https://pypi.org/project/redis/) for Redis.
|
||||
- [pylibmc](https://pypi.org/project/pylibmc/) for memcached
|
||||
- [s3werkzeugcache](https://pypi.python.org/pypi/s3werkzeugcache) for S3
|
||||
- [minio-flask-cache](https://github.com/greggailly/minio-flask-cache) for MinIO or other S3 compatible service
|
||||
|
||||
## Celery Flower
|
||||
|
||||
Flower is a web based tool for monitoring the Celery cluster which you can install from pip:
|
||||
|
||||
@@ -15,8 +15,9 @@ fail-fast behavior ensures operators are immediately aware of infrastructure iss
|
||||
|
||||
Superset uses [Flask-Caching](https://flask-caching.readthedocs.io/) for caching purposes.
|
||||
Flask-Caching supports various caching backends, including Redis (recommended), Memcached,
|
||||
SimpleCache (in-memory), MinIO/S3, or the local filesystem.
|
||||
[Custom cache backends](https://flask-caching.readthedocs.io/en/latest/#custom-cache-backends) are also supported.
|
||||
SimpleCache (in-memory), or the local filesystem.
|
||||
[Custom cache backends](https://flask-caching.readthedocs.io/en/latest/#custom-cache-backends)
|
||||
are also supported.
|
||||
|
||||
Caching can be configured by providing dictionaries in
|
||||
`superset_config.py` that comply with [the Flask-Caching config specifications](https://flask-caching.readthedocs.io/en/latest/#configuring-flask-caching).
|
||||
@@ -46,7 +47,6 @@ In order to use dedicated cache stores, additional python libraries must be inst
|
||||
- For Redis: we recommend the [redis](https://pypi.python.org/pypi/redis) Python package
|
||||
- Memcached: we recommend using [pylibmc](https://pypi.org/project/pylibmc/) client library as
|
||||
`python-memcached` does not handle storing binary data correctly.
|
||||
- MinIO (S3): we recommend using the [minio-flask-cache](https://github.com/greggailly/minio-flask-cache) package
|
||||
|
||||
These libraries can be installed using pip.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ code is less ambiguous and is unique to all regions in the world.
|
||||
## Included Maps
|
||||
|
||||
The current list of countries can be found in the src
|
||||
[plugin-chart-country-map/src/countries.ts](https://github.com/apache/superset/blob/master/superset-frontend/plugins/plugin-chart-country-map/src/countries.ts)
|
||||
[legacy-plugin-chart-country-map/src/countries.ts](https://github.com/apache/superset/blob/master/superset-frontend/plugins/legacy-plugin-chart-country-map/src/countries.ts)
|
||||
|
||||
The Country Maps visualization already ships with the maps for the following countries:
|
||||
|
||||
@@ -31,10 +31,10 @@ The Country Maps visualization already ships with the maps for the following cou
|
||||
## Adding a New Country
|
||||
|
||||
To add a new country to the list, you'd have to edit files in
|
||||
[@superset-ui/plugin-chart-country-map](https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-country-map).
|
||||
[@superset-ui/legacy-plugin-chart-country-map](https://github.com/apache/superset/tree/master/superset-frontend/plugins/legacy-plugin-chart-country-map).
|
||||
|
||||
1. Generate a new GeoJSON file for your country following the guide in [this Jupyter notebook](https://github.com/apache/superset/blob/master/superset-frontend/plugins/plugin-chart-country-map/scripts/Country%20Map%20GeoJSON%20Generator.ipynb).
|
||||
2. Edit the countries list in [plugin-chart-country-map/src/countries.ts](https://github.com/apache/superset/blob/master/superset-frontend/plugins/plugin-chart-country-map/src/countries.ts).
|
||||
1. Generate a new GeoJSON file for your country following the guide in [this Jupyter notebook](https://github.com/apache/superset/blob/master/superset-frontend/plugins/legacy-plugin-chart-country-map/scripts/Country%20Map%20GeoJSON%20Generator.ipynb).
|
||||
2. Edit the countries list in [legacy-plugin-chart-country-map/src/countries.ts](https://github.com/apache/superset/blob/master/superset-frontend/plugins/legacy-plugin-chart-country-map/src/countries.ts).
|
||||
3. Install superset-frontend dependencies: `cd superset-frontend && npm install`
|
||||
4. Verify your countries in Superset plugins storybook: `npm run plugins:storybook`.
|
||||
5. Build and install Superset from source code.
|
||||
|
||||
@@ -60,12 +60,12 @@
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.5",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/core": "^1.15.46",
|
||||
"antd": "^6.5.2",
|
||||
"baseline-browser-mapping": "^2.11.8",
|
||||
"baseline-browser-mapping": "^2.11.6",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.2",
|
||||
"js-yaml": "^5.2.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
|
||||
@@ -89,9 +89,9 @@
|
||||
},
|
||||
{
|
||||
"name": "SOFT_DELETE",
|
||||
"default": true,
|
||||
"default": false,
|
||||
"lifecycle": "development",
|
||||
"description": "Temporary rollout / kill-switch gate for soft delete (off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Retained through this release as the move-back lever; removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once post-flip confidence is established."
|
||||
"description": "Temporary rollout / kill-switch gate for soft delete (default off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once soft delete is stable."
|
||||
},
|
||||
{
|
||||
"name": "TABLE_V2_TIME_COMPARISON_ENABLED",
|
||||
|
||||
@@ -423,7 +423,7 @@ echo "sqlalchemy-cratedb" >> ./docker/requirements-local.txt
|
||||
#### Databend
|
||||
|
||||
The recommended connector library for Databend is [databend-sqlalchemy](https://pypi.org/project/databend-sqlalchemy/).
|
||||
Superset has been tested on `databend-sqlalchemy>=0.5.5`.
|
||||
Superset has been tested on `databend-sqlalchemy>=0.2.3`.
|
||||
|
||||
The recommended connection string is:
|
||||
|
||||
@@ -434,7 +434,7 @@ databend://{username}:{password}@{host}:{port}/{database_name}
|
||||
Here's a connection string example of Superset connecting to a Databend database:
|
||||
|
||||
```
|
||||
databend://user:password@localhost:8000/default?sslmode=disable
|
||||
databend://user:password@localhost:8000/default?secure=false
|
||||
```
|
||||
|
||||
#### Databricks
|
||||
|
||||
@@ -4787,86 +4787,86 @@
|
||||
dependencies:
|
||||
apg-lite "^1.0.4"
|
||||
|
||||
"@swc/core-darwin-arm64@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz#345ce6a1bf4033da189c2e3eff1244190195d15b"
|
||||
integrity sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==
|
||||
"@swc/core-darwin-arm64@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.46.tgz#393903c7eda790dbd89abd8fa0afdd9041543e5f"
|
||||
integrity sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==
|
||||
|
||||
"@swc/core-darwin-x64@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz#f3debf50b5c1602bf392acb412bd33fd6d7e4f98"
|
||||
integrity sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==
|
||||
"@swc/core-darwin-x64@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.46.tgz#ddf16787e320636621180df480a3490fd9a868ca"
|
||||
integrity sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz#14a247a12c6d3de1ee63fa4fdbf5a4302936b5d6"
|
||||
integrity sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==
|
||||
"@swc/core-linux-arm-gnueabihf@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.46.tgz#7bee01b7311c43b913771ef9c7012931871de73b"
|
||||
integrity sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==
|
||||
|
||||
"@swc/core-linux-arm64-gnu@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz#3b8d09c481ae51c7b72d98fb6ce98f7b90065a1a"
|
||||
integrity sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==
|
||||
"@swc/core-linux-arm64-gnu@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.46.tgz#964596d757d18f04a02873d85a3660416c09c187"
|
||||
integrity sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==
|
||||
|
||||
"@swc/core-linux-arm64-musl@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz#7ff2baa16e67b29017fdf7c6b69e40de7920ce1a"
|
||||
integrity sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==
|
||||
"@swc/core-linux-arm64-musl@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.46.tgz#213d3ece772689a8166ed51064836346c6ce1c2a"
|
||||
integrity sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==
|
||||
|
||||
"@swc/core-linux-ppc64-gnu@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz#a3841982fe2eb2d889648c8e212b6d821db316d6"
|
||||
integrity sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==
|
||||
"@swc/core-linux-ppc64-gnu@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.46.tgz#4d2ec554103c6bef60cc1e294f374ea5a5edaf78"
|
||||
integrity sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==
|
||||
|
||||
"@swc/core-linux-s390x-gnu@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz#edbfd705d6285f7dce48915871478bc9603904c3"
|
||||
integrity sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==
|
||||
"@swc/core-linux-s390x-gnu@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.46.tgz#097a19792ec22e2f51f6bfac02da1e0b3f5e5bb1"
|
||||
integrity sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==
|
||||
|
||||
"@swc/core-linux-x64-gnu@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz#e7f61a7771d6a9b5b274521ba61809b3d7644325"
|
||||
integrity sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==
|
||||
"@swc/core-linux-x64-gnu@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.46.tgz#39c1ca215f9ca643a4aa3ca6250cc38ba5f5c673"
|
||||
integrity sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==
|
||||
|
||||
"@swc/core-linux-x64-musl@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz#7c1ef8305444bcc7894de177fe225f2d8f3be609"
|
||||
integrity sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==
|
||||
"@swc/core-linux-x64-musl@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.46.tgz#323a720bc965fffeedacdc3167b46a291553b5e0"
|
||||
integrity sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==
|
||||
|
||||
"@swc/core-win32-arm64-msvc@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz#953856d26b28956d1a18ef10e5f221202b2cb8f1"
|
||||
integrity sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==
|
||||
"@swc/core-win32-arm64-msvc@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.46.tgz#9c2cfd2a59be74671a018097b8914f8cfbcc698d"
|
||||
integrity sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==
|
||||
|
||||
"@swc/core-win32-ia32-msvc@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz#2743a5bccc49f252c23bad3135193640cbdcef3a"
|
||||
integrity sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==
|
||||
"@swc/core-win32-ia32-msvc@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.46.tgz#bd7bd009a47b0f9826212e7ed36385d32fe193d8"
|
||||
integrity sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==
|
||||
|
||||
"@swc/core-win32-x64-msvc@1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz#9674ad0c9187b7cbe5cc3080b31b960d3ee688b9"
|
||||
integrity sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==
|
||||
"@swc/core-win32-x64-msvc@1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.46.tgz#8371845a5bdb330cf05b009f602bb8c4636c6beb"
|
||||
integrity sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==
|
||||
|
||||
"@swc/core@^1.15.40", "@swc/core@^1.15.47":
|
||||
version "1.15.47"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.47.tgz#6226e842160e247eb79a9aeac1095ebddb56639f"
|
||||
integrity sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==
|
||||
"@swc/core@^1.15.40", "@swc/core@^1.15.46":
|
||||
version "1.15.46"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.46.tgz#8acc0f68ee55010fdc876adf2a8faf0b097c681b"
|
||||
integrity sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
"@swc/types" "^0.1.27"
|
||||
optionalDependencies:
|
||||
"@swc/core-darwin-arm64" "1.15.47"
|
||||
"@swc/core-darwin-x64" "1.15.47"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.15.47"
|
||||
"@swc/core-linux-arm64-gnu" "1.15.47"
|
||||
"@swc/core-linux-arm64-musl" "1.15.47"
|
||||
"@swc/core-linux-ppc64-gnu" "1.15.47"
|
||||
"@swc/core-linux-s390x-gnu" "1.15.47"
|
||||
"@swc/core-linux-x64-gnu" "1.15.47"
|
||||
"@swc/core-linux-x64-musl" "1.15.47"
|
||||
"@swc/core-win32-arm64-msvc" "1.15.47"
|
||||
"@swc/core-win32-ia32-msvc" "1.15.47"
|
||||
"@swc/core-win32-x64-msvc" "1.15.47"
|
||||
"@swc/core-darwin-arm64" "1.15.46"
|
||||
"@swc/core-darwin-x64" "1.15.46"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.15.46"
|
||||
"@swc/core-linux-arm64-gnu" "1.15.46"
|
||||
"@swc/core-linux-arm64-musl" "1.15.46"
|
||||
"@swc/core-linux-ppc64-gnu" "1.15.46"
|
||||
"@swc/core-linux-s390x-gnu" "1.15.46"
|
||||
"@swc/core-linux-x64-gnu" "1.15.46"
|
||||
"@swc/core-linux-x64-musl" "1.15.46"
|
||||
"@swc/core-win32-arm64-msvc" "1.15.46"
|
||||
"@swc/core-win32-ia32-msvc" "1.15.46"
|
||||
"@swc/core-win32-x64-msvc" "1.15.46"
|
||||
|
||||
"@swc/counter@^0.1.3":
|
||||
version "0.1.3"
|
||||
@@ -6453,10 +6453,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.8, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.8"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.8.tgz#42eb5ffc99fd89ca8bf30e31557b487063eec86f"
|
||||
integrity sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.6, baseline-browser-mapping@^2.9.19:
|
||||
version "2.11.6"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz#56934c812026ae4fcdb039fc790a94b9a7d81d63"
|
||||
integrity sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -7938,10 +7938,10 @@ doctrine@^2.1.0:
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
docusaurus-plugin-openapi-docs@^5.1.3:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.1.3.tgz#b8cd5f8451aaf881deb1a744a8295685f1681865"
|
||||
integrity sha512-HnpblSBdXoR39VNTIW9zWERUsMJxXOpvdQoBKyaTkUBPwCM48Z76+ndo2yO2vADq+EhWjJlfxL1DUzCrgNjThQ==
|
||||
docusaurus-plugin-openapi-docs@^5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.1.2.tgz#1ef66a26d80e2a3baf4caae4e72a0dcf85c6d608"
|
||||
integrity sha512-xlgl/8+2iqSQWdhTJay0lfXqWduaGVTYrn+9i+uT3Uk5gkeDjfQOIVnHGnIHfKg0pUjIm008fvEQVAxk3TvgaQ==
|
||||
dependencies:
|
||||
"@apidevtools/json-schema-ref-parser" "^15.3.3"
|
||||
"@redocly/openapi-core" "^2.25.2"
|
||||
@@ -7959,10 +7959,10 @@ docusaurus-plugin-openapi-docs@^5.1.3:
|
||||
swagger2openapi "^7.0.8"
|
||||
xml-formatter "^3.6.6"
|
||||
|
||||
docusaurus-theme-openapi-docs@^5.1.3:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.1.3.tgz#e23644a63785352abbc76e42760c0dfdff3669e1"
|
||||
integrity sha512-npbD1QahtjAEmrOet/86i5fTmcJX4/rPhVT+c0qKjm7StUNbyqjwchSVBQuU1rB69T51JOA9TpT/y6QcB9Xjvw==
|
||||
docusaurus-theme-openapi-docs@^5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.1.2.tgz#8591e3be971920c5eb347b9aab4bac77db5c7c31"
|
||||
integrity sha512-uJx+a4qHmpOnl+e7YzsPJSga6d6TLGEVZZPtI7KkzC/PgG7bS8E4P8hknvWqmAEahxhLWEb8izIA3up/0g9RwA==
|
||||
dependencies:
|
||||
"@hookform/error-message" "^2.0.1"
|
||||
"@reduxjs/toolkit" "^2.8.2"
|
||||
@@ -7973,7 +7973,7 @@ docusaurus-theme-openapi-docs@^5.1.3:
|
||||
crypto-js "^4.2.0"
|
||||
file-saver "^2.0.5"
|
||||
lodash "^4.17.21"
|
||||
pako "^3.0.1"
|
||||
pako "^2.1.0"
|
||||
path-browserify "^1.0.1"
|
||||
postman-code-generators "^2.0.0"
|
||||
postman-collection "^5.0.2"
|
||||
@@ -7988,7 +7988,7 @@ docusaurus-theme-openapi-docs@^5.1.3:
|
||||
rehype-raw "^7.0.0"
|
||||
remark-gfm "4.0.1"
|
||||
sass "^1.89.2"
|
||||
sass-loader "^17.0.0"
|
||||
sass-loader "^16.0.5"
|
||||
unist-util-visit "^5.0.0"
|
||||
url "^0.11.4"
|
||||
xml-formatter "^3.6.6"
|
||||
@@ -10949,9 +10949,9 @@ merge2@^1.3.0, merge2@^1.4.1:
|
||||
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
||||
|
||||
mermaid@>=11.6.0:
|
||||
version "11.16.1"
|
||||
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.16.1.tgz#57ae2342f6c45b967113b04c9258430bdd057ee8"
|
||||
integrity sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==
|
||||
version "11.16.0"
|
||||
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.16.0.tgz#dc946bc84bde9d093ba14940d49df1d9f7d8c32f"
|
||||
integrity sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==
|
||||
dependencies:
|
||||
"@braintree/sanitize-url" "^7.1.2"
|
||||
"@iconify/utils" "^3.0.2"
|
||||
@@ -12290,10 +12290,10 @@ package-manager-detector@^1.3.0:
|
||||
resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.7.0.tgz#0a6d6d3856627b8ac9331f95fc891ea81247aafd"
|
||||
integrity sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==
|
||||
|
||||
pako@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-3.0.1.tgz#3156f8fa86e3cc2ae5a36369475b14d4f05696ce"
|
||||
integrity sha512-GupotUUI0mlhugKjUs4bjOwLt3nrehy9Ys2dxC0GtgVef5cnKggkDMmf2bq2poCCuVXopWPmqsc9VDT2iJUy+w==
|
||||
pako@^2.1.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-2.2.0.tgz#246b9d4c841a8d308e484c0d03786651b143e63a"
|
||||
integrity sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==
|
||||
|
||||
param-case@^3.0.4:
|
||||
version "3.0.4"
|
||||
@@ -14182,10 +14182,12 @@ safe-regex-test@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
sass-loader@^17.0.0:
|
||||
version "17.0.0"
|
||||
resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-17.0.0.tgz#8f4f8864d834c1abb18f0f3d2bc653bbf29dde2a"
|
||||
integrity sha512-0Ybm8ohBQ9LcrycVrFQp/KQBNX5a3Wda9/smS0mE/xLffzEnwvV8nykOzrbiSWNzTE3IB/jiXx8O4QmDPG2+Gw==
|
||||
sass-loader@^16.0.5:
|
||||
version "16.0.8"
|
||||
resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-16.0.8.tgz#b7eef3b7947b32e2988dd760a2f7f0d547838e6b"
|
||||
integrity sha512-hcov4ZwZJIGbEuyNr9EmiTmZueyrxSToE6GOzoZnq5JM7ecRO7ttyvilPn+VmRsqiP16+VYZzVnGZj/hzZgKBA==
|
||||
dependencies:
|
||||
neo-async "^2.6.2"
|
||||
|
||||
sass@^1.89.2:
|
||||
version "1.101.0"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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 { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
|
||||
import { drag } from 'cypress/utils';
|
||||
import { interceptGet } from './utils';
|
||||
import { interceptFiltering as interceptCharts } from '../explore/utils';
|
||||
|
||||
function editDashboard() {
|
||||
cy.getBySel('edit-dashboard-button').click();
|
||||
}
|
||||
|
||||
function dragComponent(
|
||||
component = 'Unicode Cloud',
|
||||
target = 'card-title',
|
||||
withFiltering = true,
|
||||
) {
|
||||
if (withFiltering) {
|
||||
cy.getBySel('dashboard-charts-filter-search-input').type(component, {
|
||||
force: true,
|
||||
});
|
||||
cy.wait('@filtering');
|
||||
}
|
||||
cy.wait(500);
|
||||
drag(`[data-test="${target}"]`, component).to(
|
||||
'[data-test="grid-content"] [data-test="dragdroppable-object"]',
|
||||
);
|
||||
}
|
||||
|
||||
function visitEdit(sampleDashboard = SAMPLE_DASHBOARD_1) {
|
||||
interceptCharts();
|
||||
interceptGet();
|
||||
|
||||
if (sampleDashboard === SAMPLE_DASHBOARD_1) {
|
||||
cy.createSampleDashboards([0]);
|
||||
}
|
||||
|
||||
cy.visit(sampleDashboard);
|
||||
cy.wait('@get');
|
||||
editDashboard();
|
||||
cy.get('.grid-container').should('exist');
|
||||
cy.wait('@filtering');
|
||||
cy.wait(500);
|
||||
}
|
||||
|
||||
describe('Dashboard edit', () => {
|
||||
describe('Components', () => {
|
||||
beforeEach(() => {
|
||||
visitEdit();
|
||||
});
|
||||
|
||||
it('should add charts', () => {
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('.ant-modal-wrap').length > 0) {
|
||||
cy.get('body').type('{esc}', { force: true });
|
||||
cy.wait(1000);
|
||||
cy.get('.ant-modal-close').click({ force: true });
|
||||
cy.wait(500);
|
||||
}
|
||||
});
|
||||
cy.get('input[type="checkbox"]').scrollIntoView();
|
||||
cy.get('input[type="checkbox"]').click({ force: true });
|
||||
dragComponent();
|
||||
cy.getBySel('dashboard-component-chart-holder').should('have.length', 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -138,7 +138,6 @@ export function prepareDashboardFilters(
|
||||
chart_customization_config: [],
|
||||
timed_refresh_immune_slices: [],
|
||||
expanded_slices: {},
|
||||
expand_all_slices: false,
|
||||
refresh_frequency: 0,
|
||||
color_scheme: '',
|
||||
label_colors: {},
|
||||
|
||||
@@ -79,6 +79,34 @@ export function waitForChartLoad(chart: ChartSpec) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an element and drop it to another element.
|
||||
* Usage:
|
||||
* drag(source).to(target);
|
||||
*/
|
||||
export function drag(selector: string, content: string | number | RegExp) {
|
||||
const dataTransfer = { data: {} };
|
||||
return {
|
||||
to(target: string | Cypress.Chainable) {
|
||||
cy.get('.dragdroppable')
|
||||
.contains(selector, content)
|
||||
.trigger('mousedown', { which: 1, force: true });
|
||||
cy.get('.dragdroppable')
|
||||
.contains(selector, content)
|
||||
.trigger('dragstart', { dataTransfer, force: true });
|
||||
cy.get('.dragdroppable')
|
||||
.contains(selector, content)
|
||||
.trigger('drag', { force: true });
|
||||
|
||||
(typeof target === 'string' ? cy.get(target) : target)
|
||||
.trigger('dragover', { dataTransfer, force: true })
|
||||
.trigger('drop', { dataTransfer, force: true })
|
||||
.trigger('dragend', { dataTransfer, force: true })
|
||||
.trigger('mouseup', { which: 1, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resize(selector: string) {
|
||||
return {
|
||||
to(cordX: number, cordY: number) {
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent",
|
||||
"test-loud": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80%",
|
||||
"type": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit",
|
||||
"update-maps": "cd plugins/plugin-chart-country-map/scripts && jupyter nbconvert --to notebook --execute --inplace --allow-errors --ExecutePreprocessor.timeout=1200 'Country Map GeoJSON Generator.ipynb'",
|
||||
"update-maps": "cd plugins/legacy-plugin-chart-country-map/scripts && jupyter nbconvert --to notebook --execute --inplace --allow-errors --ExecutePreprocessor.timeout=1200 'Country Map GeoJSON Generator.ipynb'",
|
||||
"validate-release": "../RELEASING/validate_this_release.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -128,22 +128,24 @@
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "file:./plugins/legacy-plugin-chart-calendar",
|
||||
"@superset-ui/legacy-plugin-chart-chord": "file:./plugins/legacy-plugin-chart-chord",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "file:./plugins/legacy-plugin-chart-country-map",
|
||||
"@superset-ui/legacy-plugin-chart-horizon": "file:./plugins/legacy-plugin-chart-horizon",
|
||||
"@superset-ui/legacy-plugin-chart-paired-t-test": "file:./plugins/legacy-plugin-chart-paired-t-test",
|
||||
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "file:./plugins/legacy-plugin-chart-parallel-coordinates",
|
||||
"@superset-ui/legacy-plugin-chart-partition": "file:./plugins/legacy-plugin-chart-partition",
|
||||
"@superset-ui/legacy-plugin-chart-rose": "file:./plugins/legacy-plugin-chart-rose",
|
||||
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
|
||||
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
|
||||
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
|
||||
"@superset-ui/plugin-chart-calendar": "file:./plugins/plugin-chart-calendar",
|
||||
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
|
||||
"@superset-ui/plugin-chart-chord": "file:./plugins/plugin-chart-chord",
|
||||
"@superset-ui/plugin-chart-country-map": "file:./plugins/plugin-chart-country-map",
|
||||
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
|
||||
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
|
||||
"@superset-ui/plugin-chart-horizon": "file:./plugins/plugin-chart-horizon",
|
||||
"@superset-ui/plugin-chart-paired-t-test": "file:./plugins/plugin-chart-paired-t-test",
|
||||
"@superset-ui/plugin-chart-parallel-coordinates": "file:./plugins/plugin-chart-parallel-coordinates",
|
||||
"@superset-ui/plugin-chart-partition": "file:./plugins/plugin-chart-partition",
|
||||
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
|
||||
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
|
||||
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
|
||||
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
|
||||
"@superset-ui/plugin-chart-world-map": "file:./plugins/plugin-chart-world-map",
|
||||
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
|
||||
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
|
||||
"@types/d3-format": "^3.0.1",
|
||||
@@ -223,7 +225,7 @@
|
||||
"redux-undo": "^1.0.0-beta9-9-7",
|
||||
"rison": "^0.1.1",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"simple-zstd": "^1.4.2",
|
||||
"simple-zstd": "^2.1.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"urijs": "^1.19.8",
|
||||
@@ -262,7 +264,7 @@
|
||||
"@storybook/react-webpack5": "10.5.5",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/core": "^1.15.46",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
@@ -296,7 +298,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.7",
|
||||
"baseline-browser-mapping": "^2.11.6",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ensureIsArray, getMetricLabel } from '@superset-ui/core';
|
||||
import type { QueryFormMetric, QueryFormOrderBy } from '@superset-ui/core';
|
||||
|
||||
export interface BuildSortMetricOrderbyConfig {
|
||||
/** The query's already-resolved metrics list. */
|
||||
metrics: QueryFormMetric[];
|
||||
/** The raw `timeseries_limit_metric` form-data value (single or multi). */
|
||||
timeseriesLimitMetric?: QueryFormMetric | QueryFormMetric[] | null;
|
||||
order_desc?: boolean;
|
||||
/**
|
||||
* Falls back to the first selected metric when no sort metric is set.
|
||||
* Charts ported from a legacy viz whose query_obj always had a sort
|
||||
* metric (defaulting to the first one) should set this; charts whose
|
||||
* legacy query_obj left ordering absent without one should not.
|
||||
*/
|
||||
fallbackToFirstMetric?: boolean;
|
||||
/**
|
||||
* When true, only order when `order_desc` is set (matching legacy vizzes
|
||||
* whose query_obj left the result unordered unless the operator asked
|
||||
* for descending). When false, always order (ascending unless
|
||||
* order_desc), matching legacy vizzes that ordered unconditionally.
|
||||
*/
|
||||
orderOnlyWhenDesc?: boolean;
|
||||
}
|
||||
|
||||
export interface SortMetricOrderby {
|
||||
/** `metrics`, with the sort metric appended if it wasn't already selected. */
|
||||
metrics: QueryFormMetric[];
|
||||
orderby: QueryFormOrderBy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a chart's sort metric and builds the corresponding query_obj
|
||||
* `orderby`, appending the sort metric to `metrics` if it isn't already
|
||||
* selected (so its value is present in the result to sort by). Several
|
||||
* charts ported from the legacy chart-data pipeline share this exact
|
||||
* shape with only the fallback/gating policy differing per their own
|
||||
* legacy `query_obj` behavior -- see `fallbackToFirstMetric` and
|
||||
* `orderOnlyWhenDesc`.
|
||||
*/
|
||||
export function buildSortMetricOrderby({
|
||||
metrics,
|
||||
timeseriesLimitMetric,
|
||||
order_desc: orderDesc,
|
||||
fallbackToFirstMetric = false,
|
||||
orderOnlyWhenDesc = false,
|
||||
}: BuildSortMetricOrderbyConfig): SortMetricOrderby {
|
||||
const sortByMetric =
|
||||
ensureIsArray(timeseriesLimitMetric)[0] ??
|
||||
(fallbackToFirstMetric ? metrics[0] : undefined);
|
||||
|
||||
if (!sortByMetric) {
|
||||
return { metrics, orderby: [] };
|
||||
}
|
||||
|
||||
const sortByLabel = getMetricLabel(sortByMetric);
|
||||
const nextMetrics = metrics.some(
|
||||
metric => getMetricLabel(metric) === sortByLabel,
|
||||
)
|
||||
? metrics
|
||||
: [...metrics, sortByMetric];
|
||||
|
||||
const shouldOrder = orderOnlyWhenDesc ? Boolean(orderDesc) : true;
|
||||
|
||||
return {
|
||||
metrics: nextMetrics,
|
||||
orderby: shouldOrder ? [[sortByMetric, !orderDesc]] : [],
|
||||
};
|
||||
}
|
||||
@@ -30,4 +30,3 @@ export * from './getTemporalColumns';
|
||||
export * from './displayTimeRelatedControls';
|
||||
export * from './colorControls';
|
||||
export * from './metricColumnFilter';
|
||||
export * from './buildSortMetricOrderby';
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { buildSortMetricOrderby } from '../../src';
|
||||
|
||||
test('is a no-op when there is no sort metric and no fallback', () => {
|
||||
const result = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: undefined,
|
||||
});
|
||||
expect(result).toEqual({ metrics: ['sum__num'], orderby: [] });
|
||||
});
|
||||
|
||||
test('falls back to the first metric when configured to', () => {
|
||||
const result = buildSortMetricOrderby({
|
||||
metrics: ['sum__num', 'avg__num'],
|
||||
timeseriesLimitMetric: undefined,
|
||||
fallbackToFirstMetric: true,
|
||||
});
|
||||
expect(result.metrics).toEqual(['sum__num', 'avg__num']);
|
||||
expect(result.orderby).toEqual([['sum__num', true]]);
|
||||
});
|
||||
|
||||
test('appends the sort metric when it is not already selected', () => {
|
||||
const result = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
});
|
||||
expect(result.metrics).toEqual(['sum__num', 'count']);
|
||||
});
|
||||
|
||||
test('does not duplicate the sort metric when already selected', () => {
|
||||
const result = buildSortMetricOrderby({
|
||||
metrics: ['sum__num', 'count'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
});
|
||||
expect(result.metrics).toEqual(['sum__num', 'count']);
|
||||
});
|
||||
|
||||
test('unconditional ordering (orderOnlyWhenDesc: false) always orders, flipping direction', () => {
|
||||
const ascending = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
order_desc: false,
|
||||
});
|
||||
expect(ascending.orderby).toEqual([['count', true]]);
|
||||
|
||||
const descending = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
order_desc: true,
|
||||
});
|
||||
expect(descending.orderby).toEqual([['count', false]]);
|
||||
});
|
||||
|
||||
test('gated ordering (orderOnlyWhenDesc: true) only orders when order_desc is set', () => {
|
||||
const withoutDesc = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
orderOnlyWhenDesc: true,
|
||||
});
|
||||
expect(withoutDesc.metrics).toEqual(['sum__num', 'count']);
|
||||
expect(withoutDesc.orderby).toEqual([]);
|
||||
|
||||
const withDesc = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: 'count',
|
||||
order_desc: true,
|
||||
orderOnlyWhenDesc: true,
|
||||
});
|
||||
expect(withDesc.orderby).toEqual([['count', false]]);
|
||||
});
|
||||
|
||||
test('resolves a multi-value timeseriesLimitMetric to its first entry', () => {
|
||||
const result = buildSortMetricOrderby({
|
||||
metrics: ['sum__num'],
|
||||
timeseriesLimitMetric: ['count', 'avg__num'],
|
||||
});
|
||||
expect(result.metrics).toEqual(['sum__num', 'count']);
|
||||
expect(result.orderby).toEqual([['count', true]]);
|
||||
});
|
||||
@@ -104,19 +104,30 @@ export default class ChartClient {
|
||||
const buildQueryRegistry = getChartBuildQueryRegistry();
|
||||
|
||||
if (metaDataRegistry.has(visType)) {
|
||||
const { useLegacyApi } = metaDataRegistry.get(visType)!;
|
||||
const buildQuery =
|
||||
(await buildQueryRegistry.get(visType)) ?? (() => formData);
|
||||
const requestConfig: RequestConfig = {
|
||||
endpoint: '/api/v1/chart/data',
|
||||
jsonPayload: buildQuery(formData),
|
||||
...options,
|
||||
};
|
||||
const requestConfig: RequestConfig = useLegacyApi
|
||||
? {
|
||||
endpoint: '/explore_json/',
|
||||
postPayload: {
|
||||
form_data: buildQuery(formData),
|
||||
},
|
||||
...options,
|
||||
}
|
||||
: {
|
||||
endpoint: '/api/v1/chart/data',
|
||||
jsonPayload: {
|
||||
query_context: buildQuery(formData),
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
return this.client.post(requestConfig).then(response => {
|
||||
const { result } = response.json as { result?: QueryData[] };
|
||||
|
||||
return Array.isArray(result) ? result : [response.json as QueryData];
|
||||
});
|
||||
return this.client
|
||||
.post(requestConfig)
|
||||
.then(response =>
|
||||
Array.isArray(response.json) ? response.json : [response.json],
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unknown chart type: ${visType}`));
|
||||
|
||||
@@ -21,6 +21,7 @@ import { render, waitFor, configure, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import StatefulChart from './StatefulChart';
|
||||
import getChartControlPanelRegistry from '../registries/ChartControlPanelRegistrySingleton';
|
||||
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
|
||||
import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton';
|
||||
|
||||
// Configure testing library to use data-test attribute
|
||||
@@ -28,6 +29,7 @@ configure({ testIdAttribute: 'data-test' });
|
||||
|
||||
// Mock the registries
|
||||
jest.mock('../registries/ChartControlPanelRegistrySingleton');
|
||||
jest.mock('../registries/ChartMetadataRegistrySingleton');
|
||||
jest.mock('../registries/ChartBuildQueryRegistrySingleton');
|
||||
jest.mock('../clients/ChartClient');
|
||||
|
||||
@@ -65,6 +67,12 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup default registry mocks
|
||||
jest.mocked(getChartMetadataRegistry).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue({
|
||||
useLegacyApi: false,
|
||||
}),
|
||||
} as unknown as ReturnType<typeof getChartMetadataRegistry>);
|
||||
|
||||
jest.mocked(getChartBuildQueryRegistry).mockReturnValue({
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
} as unknown as ReturnType<typeof getChartBuildQueryRegistry>);
|
||||
@@ -738,10 +746,11 @@ test('resolves async (202) responses via the injected handleAsyncChartData hook'
|
||||
await waitFor(() => {
|
||||
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Delegates the raw response + job metadata (and abort signal)
|
||||
// Delegates the raw response + job metadata (and useLegacyApi + abort signal)
|
||||
expect(handleAsyncChartData).toHaveBeenCalledWith(
|
||||
{ status: 202 },
|
||||
asyncJob,
|
||||
false,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
// Chart renders once the async data resolves
|
||||
@@ -789,6 +798,43 @@ test('renders synchronous (200) responses that include a response object', async
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('wraps the legacy async body as { result: [body] } for the async handler', async () => {
|
||||
const legacyBody = { job_id: 'j1', channel_id: 'c1', status: 'running' };
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
json: legacyBody,
|
||||
});
|
||||
// Force the legacy API path for this viz type
|
||||
jest.mocked(getChartMetadataRegistry).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue({ useLegacyApi: true }),
|
||||
} as unknown as ReturnType<typeof getChartMetadataRegistry>);
|
||||
const handleAsyncChartData = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ data: 'legacy result' }]);
|
||||
|
||||
const { getByTestId } = render(
|
||||
<StatefulChart
|
||||
formData={mockFormData}
|
||||
chartType="test_chart"
|
||||
hooks={{ handleAsyncChartData }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Legacy body must be wrapped to match the V1 response signature
|
||||
expect(handleAsyncChartData).toHaveBeenCalledWith(
|
||||
{ status: 202 },
|
||||
{ result: [legacyBody] },
|
||||
true,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('super-chart')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('does not apply a superseded async response over a newer one', async () => {
|
||||
mockChartClient.client.post.mockResolvedValue({
|
||||
response: { status: 202 } as Response,
|
||||
@@ -978,7 +1024,7 @@ test('passes an abort signal to the async handler and aborts it on unmount', asy
|
||||
response: { status: 202 } as Response,
|
||||
json: { job_id: 'j', channel_id: 'c' },
|
||||
});
|
||||
// Typed with a rest param so mock.calls is indexable (the 3rd arg is the signal)
|
||||
// Typed with a rest param so mock.calls is indexable (the 4th arg is the signal)
|
||||
const handleAsyncChartData = jest.fn(
|
||||
(..._args: unknown[]) => new Promise<never>(() => {}), // never resolves
|
||||
);
|
||||
@@ -994,7 +1040,7 @@ test('passes an abort signal to the async handler and aborts it on unmount', asy
|
||||
await waitFor(() => {
|
||||
expect(handleAsyncChartData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const signal = handleAsyncChartData.mock.calls[0][2] as AbortSignal;
|
||||
const signal = handleAsyncChartData.mock.calls[0][3] as AbortSignal;
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { Loading } from '../../components/Loading';
|
||||
import ChartClient from '../clients/ChartClient';
|
||||
import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton';
|
||||
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
|
||||
import getChartControlPanelRegistry from '../registries/ChartControlPanelRegistrySingleton';
|
||||
import SuperChart from './SuperChart';
|
||||
|
||||
@@ -280,6 +281,9 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
}
|
||||
finalFormData.viz_type = vizType;
|
||||
|
||||
// Get chart metadata
|
||||
const { useLegacyApi } = getChartMetadataRegistry().get(vizType) || {};
|
||||
|
||||
// Build query using the chart's buildQuery function
|
||||
const buildQuery = await getChartBuildQueryRegistry().get(vizType);
|
||||
let queryContext;
|
||||
@@ -291,20 +295,31 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
queryContext = buildQueryContext(finalFormData);
|
||||
}
|
||||
|
||||
// Ensure query_context is properly formatted for the API
|
||||
if (!queryContext.queries) {
|
||||
// Ensure query_context is properly formatted for new API
|
||||
if (!useLegacyApi && !queryContext.queries) {
|
||||
queryContext = { queries: [queryContext] };
|
||||
}
|
||||
const endpoint = useLegacyApi ? '/explore_json/' : '/api/v1/chart/data';
|
||||
|
||||
const requestConfig: RequestConfig = {
|
||||
endpoint: '/api/v1/chart/data',
|
||||
endpoint,
|
||||
signal: controller.signal,
|
||||
...(timeout && { timeout: timeout * 1000 }),
|
||||
jsonPayload: {
|
||||
};
|
||||
|
||||
if (useLegacyApi) {
|
||||
requestConfig.postPayload = {
|
||||
form_data: {
|
||||
...finalFormData,
|
||||
...(force && { force: true }),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
requestConfig.jsonPayload = {
|
||||
...queryContext,
|
||||
...(force && { force: true }),
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const clientResponse =
|
||||
await chartClientRef.current!.client.post(requestConfig);
|
||||
@@ -332,10 +347,18 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
'the async handler or disable GLOBAL_ASYNC_QUERIES for this chart.',
|
||||
);
|
||||
}
|
||||
// The async handler (handleChartDataResponse) expects the V1 chart data
|
||||
// response signature. The legacy endpoint returns a flat body, so wrap
|
||||
// it as { result: [body] } exactly like legacyChartDataRequest does for
|
||||
// the standard chart path; the V1 body is already correctly shaped.
|
||||
const asyncPayload = useLegacyApi
|
||||
? ({ result: [clientResponse.json] } as JsonObject)
|
||||
: (clientResponse.json as JsonObject);
|
||||
responseData = ensureIsArray(
|
||||
await hooks.handleAsyncChartData(
|
||||
rawResponse,
|
||||
clientResponse.json as JsonObject,
|
||||
asyncPayload,
|
||||
useLegacyApi,
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
@@ -351,8 +374,10 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
: [clientResponse.json]
|
||||
) as JsonObject[];
|
||||
|
||||
// Handle the nested result structure from the API
|
||||
responseData = (rows[0]?.result ? rows[0].result : rows) as QueryData[];
|
||||
// Handle the nested result structure from the new API
|
||||
responseData = (
|
||||
!useLegacyApi && rows[0]?.result ? rows[0].result : rows
|
||||
) as QueryData[];
|
||||
}
|
||||
|
||||
// Don't pair this request's data with newer props or fire a stale onLoad
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface ChartMetadataConfig {
|
||||
supportedAnnotationTypes?: string[];
|
||||
thumbnail: string;
|
||||
thumbnailDark?: string;
|
||||
useLegacyApi?: boolean;
|
||||
behaviors?: Behavior[];
|
||||
exampleGallery?: ExampleImage[];
|
||||
tags?: string[];
|
||||
@@ -74,6 +75,8 @@ export default class ChartMetadata {
|
||||
|
||||
thumbnailDark?: string;
|
||||
|
||||
useLegacyApi: boolean;
|
||||
|
||||
behaviors: Behavior[];
|
||||
|
||||
datasourceCount: number;
|
||||
@@ -109,6 +112,7 @@ export default class ChartMetadata {
|
||||
supportedAnnotationTypes = [],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi = false,
|
||||
behaviors = [],
|
||||
datasourceCount = 1,
|
||||
enableNoResults = true,
|
||||
@@ -140,6 +144,7 @@ export default class ChartMetadata {
|
||||
this.supportedAnnotationTypes = supportedAnnotationTypes;
|
||||
this.thumbnail = thumbnail;
|
||||
this.thumbnailDark = thumbnailDark;
|
||||
this.useLegacyApi = useLegacyApi;
|
||||
this.behaviors = behaviors;
|
||||
this.datasourceCount = datasourceCount;
|
||||
this.enableNoResults = enableNoResults;
|
||||
|
||||
@@ -75,6 +75,7 @@ type Hooks = {
|
||||
handleAsyncChartData?: (
|
||||
response: Response,
|
||||
json: JsonObject,
|
||||
useLegacyApi?: boolean,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<QueryData[]> | QueryData[];
|
||||
} & PlainObject;
|
||||
|
||||
@@ -34,9 +34,8 @@ export const DropdownButton = ({
|
||||
const { type: buttonType } = rest;
|
||||
// divider implementation for default (non-primary) buttons
|
||||
const defaultBtnCss = css`
|
||||
${
|
||||
(!buttonType || buttonType === 'default') &&
|
||||
`.ant-dropdown-trigger {
|
||||
${(!buttonType || buttonType === 'default') &&
|
||||
`.ant-dropdown-trigger {
|
||||
position: relative;
|
||||
&:before {
|
||||
content: '';
|
||||
@@ -49,8 +48,7 @@ export const DropdownButton = ({
|
||||
.anticon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}`
|
||||
}
|
||||
}`}
|
||||
`;
|
||||
const button = (
|
||||
<Dropdown.Button
|
||||
@@ -60,13 +58,13 @@ export const DropdownButton = ({
|
||||
defaultBtnCss,
|
||||
css`
|
||||
.ant-btn {
|
||||
height: ${
|
||||
styleConfig?.controlHeight ?? theme.buttonControlHeightSM ?? 30
|
||||
}px;
|
||||
height: ${styleConfig?.controlHeight ??
|
||||
theme.buttonControlHeightSM ??
|
||||
30}px;
|
||||
box-shadow: ${styleConfig?.boxShadow ?? 'none'};
|
||||
font-size: ${
|
||||
styleConfig?.fontSize ?? theme.buttonFontSize ?? theme.fontSizeSM
|
||||
}px;
|
||||
font-size: ${styleConfig?.fontSize ??
|
||||
theme.buttonFontSize ??
|
||||
theme.fontSizeSM}px;
|
||||
font-weight: ${styleConfig?.fontWeight ?? theme.fontWeightStrong};
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -221,22 +221,18 @@ export const DynamicEditableTitle = memo(
|
||||
onPressEnter={handleKeyPress}
|
||||
placeholder={placeholder}
|
||||
css={css`
|
||||
${
|
||||
!canEdit &&
|
||||
`&[disabled] {
|
||||
${!canEdit &&
|
||||
`&[disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
`
|
||||
}
|
||||
`}
|
||||
font-size: ${theme.fontSizeXL}px;
|
||||
transition: auto;
|
||||
${
|
||||
inputWidth &&
|
||||
inputWidth > 0 &&
|
||||
css`
|
||||
width: ${inputWidth}px;
|
||||
`
|
||||
}
|
||||
${inputWidth &&
|
||||
inputWidth > 0 &&
|
||||
css`
|
||||
width: ${inputWidth}px;
|
||||
`}
|
||||
`}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
@@ -171,11 +171,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
{image && <ImageContainer image={image} size={size} />}
|
||||
<div
|
||||
css={(theme: SupersetTheme) => css`
|
||||
max-width: ${
|
||||
containerSize === 'large'
|
||||
? theme.sizeUnit * 150
|
||||
: theme.sizeUnit * 100
|
||||
}px;
|
||||
max-width: ${containerSize === 'large'
|
||||
? theme.sizeUnit * 150
|
||||
: theme.sizeUnit * 100}px;
|
||||
`}
|
||||
>
|
||||
{title && <Title size={effectiveTextSize}>{title}</Title>}
|
||||
|
||||
@@ -73,16 +73,14 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
|
||||
const closeButtonWidth = theme.sizeUnit * 14;
|
||||
|
||||
return css`
|
||||
${
|
||||
responsive &&
|
||||
css`
|
||||
max-width: ${maxWidth ?? '900px'};
|
||||
padding-left: ${theme.sizeUnit * 3}px;
|
||||
padding-right: ${theme.sizeUnit * 3}px;
|
||||
padding-bottom: 0;
|
||||
top: 0;
|
||||
`
|
||||
}
|
||||
${responsive &&
|
||||
css`
|
||||
max-width: ${maxWidth ?? '900px'};
|
||||
padding-left: ${theme.sizeUnit * 3}px;
|
||||
padding-right: ${theme.sizeUnit * 3}px;
|
||||
padding-bottom: 0;
|
||||
top: 0;
|
||||
`}
|
||||
|
||||
.ant-modal-container {
|
||||
background-color: ${theme.colorBgContainer};
|
||||
@@ -170,46 +168,40 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
${
|
||||
draggable &&
|
||||
css`
|
||||
.ant-modal-header {
|
||||
padding: 0;
|
||||
${draggable &&
|
||||
css`
|
||||
.ant-modal-header {
|
||||
padding: 0;
|
||||
|
||||
.draggable-trigger {
|
||||
cursor: move;
|
||||
padding: ${theme.sizeUnit * 4}px ${closeButtonWidth}px
|
||||
${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
|
||||
width: 100%;
|
||||
.draggable-trigger {
|
||||
cursor: move;
|
||||
padding: ${theme.sizeUnit * 4}px ${closeButtonWidth}px
|
||||
${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
${resizable &&
|
||||
css`
|
||||
.resizable {
|
||||
pointer-events: all;
|
||||
|
||||
.resizable-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-modal-container {
|
||||
height: 100%;
|
||||
|
||||
.ant-modal-body {
|
||||
height: ${hideFooter
|
||||
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
|
||||
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`};
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
${
|
||||
resizable &&
|
||||
css`
|
||||
.resizable {
|
||||
pointer-events: all;
|
||||
|
||||
.resizable-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-modal-container {
|
||||
height: 100%;
|
||||
|
||||
.ant-modal-body {
|
||||
height: ${
|
||||
hideFooter
|
||||
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
|
||||
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
|
||||
@@ -51,10 +51,8 @@ const StyledTabs = ({
|
||||
.ant-tabs-body-holder {
|
||||
overflow: ${allowOverflow ? 'visible' : 'auto'};
|
||||
${fullHeight && 'height: 100%;'}
|
||||
${
|
||||
contentHeight &&
|
||||
`height: ${typeof contentHeight === 'number' ? `${contentHeight}px` : contentHeight};`
|
||||
}
|
||||
${contentHeight &&
|
||||
`height: ${typeof contentHeight === 'number' ? `${contentHeight}px` : contentHeight};`}
|
||||
${contentPadding}
|
||||
}
|
||||
.ant-tabs-body {
|
||||
@@ -68,11 +66,9 @@ const StyledTabs = ({
|
||||
margin: 0;
|
||||
}
|
||||
.ant-tabs-nav-wrap {
|
||||
${
|
||||
!(tabBarStyle && 'paddingLeft' in tabBarStyle)
|
||||
? `padding: 0 ${theme.sizeUnit * 4}px;`
|
||||
: ''
|
||||
}
|
||||
${!(tabBarStyle && 'paddingLeft' in tabBarStyle)
|
||||
? `padding: 0 ${theme.sizeUnit * 4}px;`
|
||||
: ''}
|
||||
}
|
||||
.ant-tabs-tab {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_DECK_MULTI_MAX_SLICES,
|
||||
getBootstrapDataFromDocument,
|
||||
getDeckMultiMaxSlices,
|
||||
getDefaultMapRenderer,
|
||||
getMapProviderMapStyle,
|
||||
getMapboxApiKeyFromBootstrap,
|
||||
@@ -60,16 +58,6 @@ test('Mapbox key helpers report absence and presence from bootstrap data', () =>
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('deck.gl multi-slice cap falls back to the default when unset', () => {
|
||||
expect(getDeckMultiMaxSlices({ common: { conf: {} } })).toBe(
|
||||
DEFAULT_DECK_MULTI_MAX_SLICES,
|
||||
);
|
||||
expect(getDeckMultiMaxSlices(undefined)).toBe(DEFAULT_DECK_MULTI_MAX_SLICES);
|
||||
expect(
|
||||
getDeckMultiMaxSlices({ common: { conf: { DECK_MULTI_MAX_SLICES: 5 } } }),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
test('bootstrap data helper parses document data safely', () => {
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
|
||||
common: { conf: { MAPBOX_API_KEY: 'pk.document' } },
|
||||
|
||||
@@ -83,13 +83,10 @@ type BootstrapData = {
|
||||
conf?: {
|
||||
DEFAULT_MAP_RENDERER?: unknown;
|
||||
MAPBOX_API_KEY?: unknown;
|
||||
DECK_MULTI_MAX_SLICES?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_DECK_MULTI_MAX_SLICES = 50;
|
||||
|
||||
export function getBootstrapDataFromDocument(): unknown {
|
||||
/* istanbul ignore if -- a missing document only occurs in SSR/worker
|
||||
contexts, which Jest cannot simulate: jsdom pins `document` as a
|
||||
@@ -121,16 +118,6 @@ export function hasMapboxApiKey(
|
||||
return getMapboxApiKeyFromBootstrap(bootstrapData).trim().length > 0;
|
||||
}
|
||||
|
||||
export function getDeckMultiMaxSlices(
|
||||
bootstrapData: unknown = getBootstrapDataFromDocument(),
|
||||
): number {
|
||||
const maxSlices = (bootstrapData as BootstrapData | undefined)?.common?.conf
|
||||
?.DECK_MULTI_MAX_SLICES;
|
||||
return typeof maxSlices === 'number' && Number.isFinite(maxSlices)
|
||||
? maxSlices
|
||||
: DEFAULT_DECK_MULTI_MAX_SLICES;
|
||||
}
|
||||
|
||||
export function getDefaultMapRenderer(
|
||||
bootstrapData: unknown = getBootstrapDataFromDocument(),
|
||||
): MapProvider {
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('ChartClient', () => {
|
||||
});
|
||||
|
||||
describe('.loadQueryData(formData, options)', () => {
|
||||
test('returns a promise of query data for known chart type', async () => {
|
||||
test('returns a promise of query data for known chart type', () => {
|
||||
getChartMetadataRegistry().registerValue(
|
||||
VizType.WordCloud,
|
||||
new ChartMetadata({ name: 'Word Cloud', thumbnail: '' }),
|
||||
@@ -132,18 +132,14 @@ describe('ChartClient', () => {
|
||||
VizType.WordCloud,
|
||||
(formData: QueryFormData) => buildQueryContext(formData),
|
||||
);
|
||||
// The real /api/v1/chart/data endpoint wraps its results in
|
||||
// `{ result: [...] }`, not a bare array.
|
||||
fetchMock.post('glob:*/api/v1/chart/data', {
|
||||
result: [
|
||||
{
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchMock.post('glob:*/api/v1/chart/data', [
|
||||
{
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
return expect(
|
||||
chartClient.loadQueryData({
|
||||
granularity: 'minute',
|
||||
viz_type: VizType.WordCloud,
|
||||
@@ -155,16 +151,6 @@ describe('ChartClient', () => {
|
||||
field2: 'def',
|
||||
},
|
||||
]);
|
||||
|
||||
// The query context fields must be posted at the top level of the
|
||||
// request body -- the endpoint's schema does not expect them nested
|
||||
// under a `query_context` key.
|
||||
const calls = fetchMock.callHistory.calls('glob:*/api/v1/chart/data');
|
||||
const requestBody = JSON.parse(
|
||||
(calls[0].options as RequestInit).body as string,
|
||||
);
|
||||
expect(requestBody.query_context).toBeUndefined();
|
||||
expect(requestBody.datasource).toEqual({ id: 1, type: 'table' });
|
||||
});
|
||||
test('returns a promise that rejects for unknown chart type', () =>
|
||||
expect(
|
||||
@@ -174,6 +160,42 @@ describe('ChartClient', () => {
|
||||
datasource: '1__table',
|
||||
}),
|
||||
).rejects.toEqual(new Error('Unknown chart type: rainbow_3d_pie')));
|
||||
|
||||
test('fetches data from the legacy API if ChartMetadata has useLegacyApi=true,', () => {
|
||||
// note legacy charts do not register a buildQuery function in the registry
|
||||
getChartMetadataRegistry().registerValue(
|
||||
'word_cloud_legacy',
|
||||
new ChartMetadata({
|
||||
name: 'Legacy Word Cloud',
|
||||
thumbnail: '.png',
|
||||
useLegacyApi: true,
|
||||
}),
|
||||
);
|
||||
|
||||
fetchMock.post('glob:*/api/v1/chart/data', () =>
|
||||
Promise.reject(new Error('Unexpected all to v1 API')),
|
||||
);
|
||||
|
||||
// post `Superset.route_base = ""`, the legacy endpoint
|
||||
// collapsed from `/superset/explore_json/` to `/explore_json/`.
|
||||
fetchMock.post('glob:*/explore_json/', {
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
});
|
||||
|
||||
return expect(
|
||||
chartClient.loadQueryData({
|
||||
granularity: 'minute',
|
||||
viz_type: 'word_cloud_legacy',
|
||||
datasource: '1__table',
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.loadDatasource(datasourceKey, options)', () => {
|
||||
|
||||
@@ -19,16 +19,10 @@
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
import { Modal, Input } from '../core';
|
||||
import { isFeatureEnabled } from '../../helpers/featureFlags';
|
||||
|
||||
/**
|
||||
* Delete confirmation modal, used throughout Superset for delete operations.
|
||||
*
|
||||
* The modal has two modes. Destructive mode demands the user type "DELETE"
|
||||
* before the action is enabled. Recoverable mode — what `SOFT_DELETE` turns
|
||||
* on — archives instead of removing, so the action reads "Archive" and the
|
||||
* type-to-confirm friction is deliberately dropped: reduced friction is what
|
||||
* a reversible action earns.
|
||||
* Delete confirmation modal that requires typing "DELETE" to confirm.
|
||||
* Used throughout Superset for destructive delete operations.
|
||||
*
|
||||
* Provides primitives for tests to compose deletion flows.
|
||||
*/
|
||||
@@ -102,40 +96,4 @@ export class DeleteConfirmationModal extends Modal {
|
||||
await expect(confirmButton).toBeEnabled({ timeout: options?.timeout });
|
||||
await confirmButton.click(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms the deletion using whichever interaction the modal is in.
|
||||
*
|
||||
* Which mode is in force is a property of the instance, not of the caller,
|
||||
* so the flag decides rather than the test — the same spec then covers the
|
||||
* hard-delete and archive paths without being rewritten when the default
|
||||
* flips, and keeps covering hard delete for deployments that turn the
|
||||
* toggle back off.
|
||||
*
|
||||
* Neither branch is merely tolerant: the recoverable branch asserts the
|
||||
* confirmation input is genuinely *absent* rather than skipping past it,
|
||||
* so a regression that dropped the typed confirmation from destructive
|
||||
* mode still fails here instead of quietly passing.
|
||||
*
|
||||
* Assumes the modal's mode follows `SOFT_DELETE` alone. That holds
|
||||
* everywhere except a bulk selection containing semantic views, which
|
||||
* stays destructive even with the flag on — such a flow should drive
|
||||
* {@link fillConfirmationInput} and {@link clickDelete} directly.
|
||||
*
|
||||
* @param confirmationText - Text typed in destructive mode
|
||||
*
|
||||
* @example
|
||||
* const deleteModal = new DeleteConfirmationModal(page);
|
||||
* await deleteModal.waitForVisible();
|
||||
* await deleteModal.confirmDeletion();
|
||||
* await deleteModal.waitForHidden();
|
||||
*/
|
||||
async confirmDeletion(confirmationText = 'DELETE'): Promise<void> {
|
||||
if (await isFeatureEnabled(this.page, 'SOFT_DELETE')) {
|
||||
await expect(this.confirmationInput.element).toHaveCount(0);
|
||||
} else {
|
||||
await this.fillConfirmationInput(confirmationText);
|
||||
}
|
||||
await this.clickDelete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Unconditional pause between drag events, letting react-dnd's HTML5 backend
|
||||
* commit its monitor state before the next event fires. Deliberately not in
|
||||
* `TIMEOUT`: that object holds wait *ceilings* (a wait may finish sooner),
|
||||
* whereas this is a fixed sleep that always costs what it says.
|
||||
*/
|
||||
const REACT_DND_SETTLE_MS = 50;
|
||||
|
||||
/**
|
||||
* Drives an HTML5 drag-and-drop using synthetic native drag events.
|
||||
*
|
||||
* The dashboard grid uses react-dnd with the HTML5 backend
|
||||
* (`react-dnd-html5-backend`), which listens for native `dragstart` /
|
||||
* `dragenter` / `dragover` / `drop` events rather than the mouse events that
|
||||
* Playwright's built-in `locator.dragTo()` produces. To trigger it we dispatch
|
||||
* the native drag sequence ourselves, threading a single shared `DataTransfer`
|
||||
* object through every event so react-dnd's monitor sees a consistent payload.
|
||||
*
|
||||
* Mirrors the synthetic-event sequence used by the deprecated Cypress `drag`
|
||||
* helper (cypress-base/cypress/utils/index.ts).
|
||||
*
|
||||
* @param page - Playwright page (used to mint the shared DataTransfer)
|
||||
* @param source - The draggable element (or a descendant; drag events bubble)
|
||||
* @param target - The drop target element
|
||||
*/
|
||||
export async function html5DragAndDrop(
|
||||
page: Page,
|
||||
source: Locator,
|
||||
target: Locator,
|
||||
): Promise<void> {
|
||||
// Note: we intentionally do not scrollIntoView the source. The chart card list
|
||||
// is virtualized, so a separate scroll action can detach the element between
|
||||
// resolution and use; dispatchEvent only requires the node to be attached.
|
||||
|
||||
// A single DataTransfer shared across every event in the sequence: react-dnd's
|
||||
// HTML5 backend reads/writes drag state through it, so reusing one handle is
|
||||
// what makes the monitor treat this as one coherent drag.
|
||||
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
|
||||
|
||||
await source.dispatchEvent('dragstart', { dataTransfer });
|
||||
// react-dnd's HTML5 backend commits monitor state (the active drag source) on a
|
||||
// microtask after dragstart; a short settle avoids a race where dragover/drop
|
||||
// fire before the backend considers a drag to be in progress.
|
||||
await page.waitForTimeout(REACT_DND_SETTLE_MS);
|
||||
// dragenter must precede dragover for react-dnd to register the hover target.
|
||||
await target.dispatchEvent('dragenter', { dataTransfer });
|
||||
await target.dispatchEvent('dragover', { dataTransfer });
|
||||
await page.waitForTimeout(REACT_DND_SETTLE_MS);
|
||||
await target.dispatchEvent('drop', { dataTransfer });
|
||||
await source.dispatchEvent('dragend', { dataTransfer });
|
||||
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
@@ -17,29 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Page, Download, Locator, expect } from '@playwright/test';
|
||||
import { Button, Input, Menu, Tabs } from '../components/core';
|
||||
import { Page, Download, Locator } from '@playwright/test';
|
||||
import { Menu } from '../components/core';
|
||||
import { DashboardFilterBar } from '../components/dashboard';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { html5DragAndDrop } from '../helpers/dnd';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
|
||||
/** Tabs of the dashboard builder side pane, by their rendered label. */
|
||||
type BuilderTab = 'Charts' | 'Layout elements';
|
||||
|
||||
/**
|
||||
* Built-in draggable layout elements, by their rendered label (see
|
||||
* `src/dashboard/components/gridComponents/new/`). Extension-provided elements
|
||||
* carry dynamic names and are not covered here.
|
||||
*/
|
||||
type LayoutElementLabel =
|
||||
| 'Tabs'
|
||||
| 'Row'
|
||||
| 'Column'
|
||||
| 'Header'
|
||||
| 'Text / Markdown'
|
||||
| 'Divider';
|
||||
|
||||
/**
|
||||
* Dashboard Page object for interacting with dashboards.
|
||||
*/
|
||||
@@ -49,28 +32,9 @@ export class DashboardPage {
|
||||
|
||||
private static readonly SELECTORS = {
|
||||
DASHBOARD_HEADER: '[data-test="dashboard-header-container"]',
|
||||
CHART_GRID_COMPONENT: '[data-test="chart-grid-component"]',
|
||||
// `:visible` so the locator empties out as loaders hide; see
|
||||
// waitForLoadersToSettle.
|
||||
LOADING_INDICATOR: '[aria-label="Loading"]:visible',
|
||||
DASHBOARD_MENU_TRIGGER: '[data-test="actions-trigger"]',
|
||||
// The header-actions-menu is the data-test for the dropdown menu content
|
||||
HEADER_ACTIONS_MENU: '[data-test="header-actions-menu"]',
|
||||
EDIT_BUTTON: '[data-test="edit-dashboard-button"]',
|
||||
BUILDER_PANE: '[data-test="dashboard-builder-sidepane"]',
|
||||
CHARTS_SEARCH: '[data-test="dashboard-charts-filter-search-input"]',
|
||||
CHART_CARD: '[data-test="chart-card"]',
|
||||
EMPTY_DROPTARGET: '[data-test="grid-content"] .empty-droptarget',
|
||||
NEW_COMPONENT: '[data-test="new-component"]',
|
||||
CHART_HOLDER: '[data-test="dashboard-component-chart-holder"]',
|
||||
GRID_CONTENT: '[data-test="grid-content"]',
|
||||
DELETE_COMPONENT: '[data-test="dashboard-delete-component-button"]',
|
||||
MARKDOWN_EDITOR: '[data-test="dashboard-markdown-editor"]',
|
||||
EDITABLE_TITLE: '[data-test="editable-title-input"]',
|
||||
// Ace exposes no data-test hooks; these are its own stable DOM classes.
|
||||
ACE_CONTENT: '.ace_content',
|
||||
ACE_TEXT_INPUT: '.ace_text-input',
|
||||
RESIZE_HANDLE_BOTTOM: '.resizable-container-handle--bottom',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -96,16 +60,12 @@ export class DashboardPage {
|
||||
|
||||
/**
|
||||
* Wait for the dashboard header to be visible.
|
||||
*
|
||||
* The header container renders well before the grid does, so this only
|
||||
* establishes that the dashboard route mounted — pair it with
|
||||
* {@link waitForChartsToLoad} before asserting on chart content.
|
||||
*/
|
||||
async waitForLoad(options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.DASHBOARD_HEADER)
|
||||
.waitFor({ state: 'visible', timeout });
|
||||
await this.page.waitForSelector(DashboardPage.SELECTORS.DASHBOARD_HEADER, {
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,80 +73,37 @@ export class DashboardPage {
|
||||
*/
|
||||
getChart(chartId: number): Locator {
|
||||
return this.page.locator(
|
||||
`${DashboardPage.SELECTORS.CHART_GRID_COMPONENT}[data-test-chart-id="${chartId}"]`,
|
||||
`[data-test="chart-grid-component"][data-test-chart-id="${chartId}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the dashboard's charts to mount and finish loading.
|
||||
*
|
||||
* Waiting only for loading indicators to clear is not enough: the grid mounts
|
||||
* its spinners after the header renders, so a "no visible loader" check
|
||||
* called straight after {@link waitForLoad} passes instantly against a
|
||||
* dashboard that has not started rendering anything. Waiting for at least one
|
||||
* chart grid component first makes the absence of loaders mean "charts
|
||||
* finished" rather than "charts have not begun".
|
||||
*
|
||||
* Only for dashboards that have charts — on an empty one this waits out
|
||||
* `timeout` rather than returning. Use {@link waitForGridToLoad} there.
|
||||
* Wait for all charts on the dashboard to finish loading.
|
||||
* Waits until no loading indicators are visible on the page.
|
||||
*/
|
||||
async waitForChartsToLoad(options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
|
||||
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.CHART_GRID_COMPONENT)
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout });
|
||||
|
||||
await this.waitForLoadersToSettle(timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the dashboard grid to mount and any loading indicators to clear.
|
||||
*
|
||||
* The counterpart to {@link waitForChartsToLoad} for a dashboard with no
|
||||
* charts on it: the grid container renders whatever the grid holds, so it
|
||||
* gives the "the page got past the header" evidence that a chart component
|
||||
* cannot. Prefer {@link waitForChartsToLoad} whenever charts are expected —
|
||||
* this cannot tell a grid that rendered empty from one whose charts have not
|
||||
* begun rendering.
|
||||
*/
|
||||
async waitForGridToLoad(options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
|
||||
|
||||
// Attached rather than visible: an empty grid collapses to zero height,
|
||||
// which Playwright counts as not visible.
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.GRID_CONTENT)
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout });
|
||||
|
||||
await this.waitForLoadersToSettle(timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once no loading indicator is visible.
|
||||
*
|
||||
* Loading indicators persist in the DOM as hidden elements after charts
|
||||
* finish, so this waits for none to be *visible* rather than for none to
|
||||
* exist. The `:visible` engine resolves to zero elements when they are all
|
||||
* hidden, which is what `detached` then matches — and it returns immediately
|
||||
* when they are already settled, with no timeout penalty.
|
||||
*
|
||||
* Deliberately not a `getComputedStyle` check in an evaluated function:
|
||||
* `display` does not inherit, so a loader inside a `display: none` ancestor
|
||||
* computes to its own `display: block` and reads as visible, hanging the wait
|
||||
* until the timeout. Playwright's visibility check accounts for ancestors.
|
||||
*
|
||||
* Loader absence is also the state of a dashboard that has not started
|
||||
* rendering, which is why every caller pairs this with a wait for the content
|
||||
* it expects.
|
||||
*/
|
||||
private async waitForLoadersToSettle(timeout: number): Promise<void> {
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.LOADING_INDICATOR)
|
||||
.first()
|
||||
.waitFor({ state: 'detached', timeout });
|
||||
// Use browser-context evaluation to check visibility directly.
|
||||
// Loading indicators ([aria-label="Loading"]) may persist in the DOM as hidden
|
||||
// elements after charts finish loading. This checks that none are currently visible,
|
||||
// returning immediately when charts are already loaded (no timeout penalty).
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const loaders = document.querySelectorAll('[aria-label="Loading"]');
|
||||
if (loaders.length === 0) return true;
|
||||
return Array.from(loaders).every(el => {
|
||||
const style = getComputedStyle(el);
|
||||
return (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
style.opacity === '0'
|
||||
);
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,13 +136,14 @@ export class DashboardPage {
|
||||
* Open the dashboard header actions menu (three-dot menu)
|
||||
*/
|
||||
async openHeaderActionsMenu(): Promise<void> {
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.DASHBOARD_MENU_TRIGGER)
|
||||
.click();
|
||||
await this.page.click(DashboardPage.SELECTORS.DASHBOARD_MENU_TRIGGER);
|
||||
// Wait for the dropdown menu to appear
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.HEADER_ACTIONS_MENU)
|
||||
.waitFor({ state: 'visible' });
|
||||
await this.page.waitForSelector(
|
||||
DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
|
||||
{
|
||||
state: 'visible',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,198 +178,4 @@ export class DashboardPage {
|
||||
await menu.selectSubmenuItem('Download', optionText);
|
||||
return downloadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter dashboard edit mode and wait for the builder side pane to appear.
|
||||
*/
|
||||
async enterEditMode(): Promise<void> {
|
||||
const editButton = new Button(
|
||||
this.page,
|
||||
DashboardPage.SELECTORS.EDIT_BUTTON,
|
||||
);
|
||||
await editButton.click();
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.BUILDER_PANE)
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/**
|
||||
* The builder side pane's tab bar (Charts / Layout elements).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Switch the builder side pane to one of its tabs.
|
||||
* @param tab - 'Charts' (existing slices) or 'Layout elements' (new components)
|
||||
*/
|
||||
private async openBuilderTab(tab: BuilderTab): Promise<void> {
|
||||
// Scoped to `.ant-tabs` because that is the root the shared Tabs component
|
||||
// expects.
|
||||
const builderTabs = new Tabs(
|
||||
this.page,
|
||||
this.page
|
||||
.locator(`${DashboardPage.SELECTORS.BUILDER_PANE} .ant-tabs`)
|
||||
.first(),
|
||||
);
|
||||
await builderTabs.clickTab(tab);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for chart-holder components currently placed on the grid.
|
||||
* Markdown components are chart holders too — use
|
||||
* {@link getMarkdownEditors} when the assertion must exclude them.
|
||||
*/
|
||||
getChartHolders(): Locator {
|
||||
return this.page.locator(DashboardPage.SELECTORS.CHART_HOLDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an existing chart from the Charts pane onto the dashboard grid.
|
||||
* Requires edit mode to be active.
|
||||
* @param sliceName - The slice name to search for and drag
|
||||
*/
|
||||
async addChartByName(sliceName: string): Promise<void> {
|
||||
await this.openBuilderTab('Charts');
|
||||
const search = new Input(this.page, DashboardPage.SELECTORS.CHARTS_SEARCH);
|
||||
await search.fill(sliceName);
|
||||
const card = this.page
|
||||
.locator(DashboardPage.SELECTORS.CHART_CARD)
|
||||
.filter({ hasText: sliceName })
|
||||
.first();
|
||||
await card.waitFor({ state: 'visible' });
|
||||
await html5DragAndDrop(this.page, card, this.dropTarget());
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a new Layout element (by its label) onto the dashboard grid.
|
||||
* Requires edit mode to be active.
|
||||
* @param label - The new-component label, e.g. 'Text / Markdown'
|
||||
*/
|
||||
async addLayoutElement(label: LayoutElementLabel): Promise<void> {
|
||||
await this.openBuilderTab('Layout elements');
|
||||
const source = this.page
|
||||
.locator(DashboardPage.SELECTORS.NEW_COMPONENT)
|
||||
.filter({ hasText: label })
|
||||
.first();
|
||||
await source.waitFor({ state: 'visible' });
|
||||
await html5DragAndDrop(this.page, source, this.dropTarget());
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid's empty drop target, which the grid renders while in edit mode.
|
||||
*
|
||||
* Only resolves while the grid is still empty. Dropping a second component
|
||||
* needs a target relative to the already-placed one, not this.
|
||||
*/
|
||||
private dropTarget(): Locator {
|
||||
return this.page.locator(DashboardPage.SELECTORS.EMPTY_DROPTARGET).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover the first placed chart-holder and click its delete button (edit mode).
|
||||
*/
|
||||
async deleteChartHolder(): Promise<void> {
|
||||
const holder = this.getChartHolders().first();
|
||||
await holder.hover();
|
||||
const deleteButton = new Button(
|
||||
this.page,
|
||||
holder.locator(DashboardPage.SELECTORS.DELETE_COMPONENT),
|
||||
);
|
||||
await deleteButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for markdown editor components on the grid.
|
||||
*/
|
||||
getMarkdownEditors(): Locator {
|
||||
return this.page.locator(DashboardPage.SELECTORS.MARKDOWN_EDITOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendered ace document inside a markdown component. Present only once
|
||||
* the component has entered its editing state.
|
||||
*
|
||||
* Exposed as a locator rather than routed through the `AceEditor` component:
|
||||
* that component reads and writes through `ace.edit(...)` in page context,
|
||||
* which both bypasses the real keystroke path under test and gives up
|
||||
* web-first retries on assertions.
|
||||
*
|
||||
* @param markdownEditor - A locator from {@link getMarkdownEditors}
|
||||
*/
|
||||
getMarkdownAceContent(markdownEditor: Locator): Locator {
|
||||
return markdownEditor.locator(DashboardPage.SELECTORS.ACE_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ace's hidden textarea inside a markdown component — the element that
|
||||
* receives keystrokes.
|
||||
*
|
||||
* @param markdownEditor - A locator from {@link getMarkdownEditors}
|
||||
*/
|
||||
getMarkdownAceInput(markdownEditor: Locator): Locator {
|
||||
return markdownEditor.locator(DashboardPage.SELECTORS.ACE_TEXT_INPUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the dashboard title, moving focus off whichever grid component holds
|
||||
* it. Committing a markdown edit needs a click on some other element, and the
|
||||
* title is the one that is always present regardless of what is on the grid.
|
||||
*
|
||||
* In edit mode the click focuses the title's input. That is a state change,
|
||||
* not a no-op — but it edits nothing on its own, so it leaves the component
|
||||
* under test untouched.
|
||||
*/
|
||||
async blurToDashboardTitle(): Promise<void> {
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.EDITABLE_TITLE)
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a grid component's bottom resize handle down by `deltaY` pixels.
|
||||
* Requires edit mode. Uses the mouse because the resize handle is driven by
|
||||
* `react-resizable`, which tracks real pointer movement.
|
||||
*
|
||||
* @param component - The grid component to resize
|
||||
* @param deltaY - Pixels to drag downwards (positive grows the component)
|
||||
* @returns The component's height before and after the drag
|
||||
*/
|
||||
async resizeComponent(
|
||||
component: Locator,
|
||||
deltaY: number,
|
||||
): Promise<{ heightBefore: number; heightAfter: number }> {
|
||||
const boxBefore = await component.boundingBox();
|
||||
if (!boxBefore) {
|
||||
throw new Error('Cannot resize a component that is not visible');
|
||||
}
|
||||
|
||||
const handle = component
|
||||
.locator(DashboardPage.SELECTORS.RESIZE_HANDLE_BOTTOM)
|
||||
.last();
|
||||
const handleBox = await handle.boundingBox();
|
||||
if (!handleBox) {
|
||||
throw new Error('Resize handle is not visible');
|
||||
}
|
||||
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
await this.page.mouse.move(startX, startY);
|
||||
await this.page.mouse.down();
|
||||
// Multiple steps so react-resizable sees a drag rather than a teleport.
|
||||
await this.page.mouse.move(startX, startY + deltaY, { steps: 10 });
|
||||
await this.page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await component.boundingBox())?.height, {
|
||||
message: 'Component height did not change after resize',
|
||||
})
|
||||
.not.toBe(boxBefore.height);
|
||||
|
||||
const boxAfter = await component.boundingBox();
|
||||
if (!boxAfter) {
|
||||
throw new Error('Component disappeared during resize');
|
||||
}
|
||||
|
||||
return { heightBefore: boxBefore.height, heightAfter: boxAfter.height };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,11 @@ test('should delete a chart with confirmation', async ({
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
await deleteModal.waitForVisible();
|
||||
|
||||
// Confirm: types "DELETE" while the modal is destructive, and goes straight
|
||||
// through once SOFT_DELETE makes it a recoverable archive instead.
|
||||
await deleteModal.confirmDeletion();
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
@@ -235,9 +237,11 @@ test('should bulk delete multiple charts', async ({
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
await deleteModal.waitForVisible();
|
||||
|
||||
// Confirm: types "DELETE" while the modal is destructive, and goes straight
|
||||
// through once SOFT_DELETE makes it a recoverable archive instead.
|
||||
await deleteModal.confirmDeletion();
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
@@ -25,13 +25,8 @@ import {
|
||||
buildSingleRowDashboardLayout,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import {
|
||||
buildFilterJsonMetadata,
|
||||
buildSelectFilter,
|
||||
} from './dashboard-test-helpers';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const FILTER_COLUMN = 'gender';
|
||||
@@ -64,10 +59,12 @@ testWithAssets(
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartId = await extractIdFromResponse(chartResp);
|
||||
const chart = await chartResp.json();
|
||||
const chartId: number = chart.id ?? chart.result?.id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
// Create dashboard with chart in position_json and a native filter in json_metadata
|
||||
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const positionJson = buildSingleRowDashboardLayout([
|
||||
{
|
||||
id: chartId,
|
||||
@@ -77,17 +74,39 @@ testWithAssets(
|
||||
},
|
||||
]);
|
||||
|
||||
const jsonMetadata = buildFilterJsonMetadata({
|
||||
chartsInScope: [chartId],
|
||||
nativeFilters: [
|
||||
buildSelectFilter({
|
||||
datasetId,
|
||||
column: FILTER_COLUMN,
|
||||
chartsInScope: [chartId],
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: [
|
||||
{
|
||||
id: filterId,
|
||||
name: 'Gender',
|
||||
}),
|
||||
filterType: 'filter_select',
|
||||
type: 'NATIVE_FILTER',
|
||||
targets: [
|
||||
{
|
||||
datasetId,
|
||||
column: { name: FILTER_COLUMN },
|
||||
},
|
||||
],
|
||||
controlValues: {
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
inverseSelection: false,
|
||||
searchAllOptions: false,
|
||||
},
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
});
|
||||
chart_configuration: {},
|
||||
cross_filters_enabled: false,
|
||||
global_chart_configuration: {
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
};
|
||||
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `clear_all_repro_${Date.now()}`,
|
||||
@@ -96,7 +115,8 @@ testWithAssets(
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashboardId = await extractIdFromResponse(dashResp);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
// Associate chart with the dashboard so it actually renders
|
||||
|
||||
@@ -81,10 +81,11 @@ test('should delete a dashboard with confirmation', async ({
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
await deleteModal.waitForVisible();
|
||||
|
||||
// Confirm: types "DELETE" while the modal is destructive, and goes straight
|
||||
// through once SOFT_DELETE makes it a recoverable archive instead. Either
|
||||
// way it waits for the action to become enabled.
|
||||
await deleteModal.confirmDeletion();
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button (waits for it to become enabled)
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
@@ -190,9 +191,11 @@ test('should bulk delete multiple dashboards', async ({
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
await deleteModal.waitForVisible();
|
||||
|
||||
// Confirm: types "DELETE" while the modal is destructive, and goes straight
|
||||
// through once SOFT_DELETE makes it a recoverable archive instead.
|
||||
await deleteModal.confirmDeletion();
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
@@ -62,8 +62,6 @@ interface TestDashboardResult {
|
||||
interface CreateTestDashboardOptions {
|
||||
/** Prefix for generated name (default: 'test_dashboard') */
|
||||
prefix?: string;
|
||||
/** Publish the dashboard on creation (default: false, the API default) */
|
||||
published?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,8 +86,6 @@ export async function createTestDashboard(
|
||||
|
||||
const response = await apiPostDashboard(page, {
|
||||
dashboard_title: name,
|
||||
// Serialized as JSON, which drops undefined — no need to omit the key.
|
||||
published: options?.published,
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
@@ -110,113 +106,6 @@ export async function createTestDashboard(
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
/** Scope covering the whole dashboard — every filter built here is unscoped. */
|
||||
const ROOT_SCOPE = { rootPath: ['ROOT_ID'], excluded: [] };
|
||||
|
||||
interface DataMask {
|
||||
filterState: Record<string, unknown>;
|
||||
extraFormData: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NativeFilterConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
filterType: string;
|
||||
type: string;
|
||||
targets: Array<{ datasetId: number; column: { name: string } }>;
|
||||
controlValues: Record<string, boolean>;
|
||||
defaultDataMask: DataMask;
|
||||
cascadeParentIds: string[];
|
||||
scope: typeof ROOT_SCOPE;
|
||||
chartsInScope: number[];
|
||||
}
|
||||
|
||||
interface SelectFilterOptions {
|
||||
/** Dataset backing the filtered column. */
|
||||
datasetId: number;
|
||||
/** Column the filter targets. */
|
||||
column: string;
|
||||
/** Charts the filter applies to. */
|
||||
chartsInScope: number[];
|
||||
/** Label shown in the filter bar (default: the column name). */
|
||||
name?: string;
|
||||
/**
|
||||
* Value preselected when the dashboard loads. Omit for a filter that starts
|
||||
* unset — the distinction is load-bearing: a preselected filter is applied to
|
||||
* the initial chart-data request, an unset one is not.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one `filter_select` native filter for a dashboard's `json_metadata`.
|
||||
* The filter id is generated here because no test needs to know it — filters are
|
||||
* addressed through the filter bar UI, not by id.
|
||||
*/
|
||||
export function buildSelectFilter(
|
||||
options: SelectFilterOptions,
|
||||
): NativeFilterConfig {
|
||||
const { datasetId, column, chartsInScope, name, defaultValue } = options;
|
||||
return {
|
||||
id: `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`,
|
||||
name: name ?? column,
|
||||
filterType: 'filter_select',
|
||||
type: 'NATIVE_FILTER',
|
||||
targets: [{ datasetId, column: { name: column } }],
|
||||
controlValues: {
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
inverseSelection: false,
|
||||
searchAllOptions: false,
|
||||
},
|
||||
defaultDataMask:
|
||||
defaultValue === undefined
|
||||
? { filterState: {}, extraFormData: {} }
|
||||
: {
|
||||
filterState: { value: [defaultValue] },
|
||||
extraFormData: {
|
||||
filters: [{ col: column, op: 'IN', val: [defaultValue] }],
|
||||
},
|
||||
},
|
||||
cascadeParentIds: [],
|
||||
scope: ROOT_SCOPE,
|
||||
chartsInScope,
|
||||
};
|
||||
}
|
||||
|
||||
interface FilterMetadataOptions {
|
||||
/** Charts the dashboard's global filter scope covers. */
|
||||
chartsInScope: number[];
|
||||
nativeFilters: NativeFilterConfig[];
|
||||
/**
|
||||
* Display Controls, serialized as-is. Kept untyped and pass-through: only one
|
||||
* spec builds them, so a second builder would be speculative.
|
||||
*/
|
||||
chartCustomizations?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `json_metadata` envelope a filtered dashboard needs. Cross-filters
|
||||
* are off so a click on one chart cannot perturb another test's assertions.
|
||||
*/
|
||||
export function buildFilterJsonMetadata(
|
||||
options: FilterMetadataOptions,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
native_filter_configuration: options.nativeFilters,
|
||||
...(options.chartCustomizations && {
|
||||
chart_customization_config: options.chartCustomizations,
|
||||
}),
|
||||
chart_configuration: {},
|
||||
cross_filters_enabled: false,
|
||||
global_chart_configuration: {
|
||||
scope: ROOT_SCOPE,
|
||||
chartsInScope: options.chartsInScope,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardChartSpec {
|
||||
/** Sent as the chart's top-level `viz_type` and injected into its params. */
|
||||
viz_type: string;
|
||||
|
||||
@@ -30,12 +30,7 @@ import {
|
||||
buildSingleRowDashboardLayout,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import {
|
||||
buildFilterJsonMetadata,
|
||||
buildSelectFilter,
|
||||
} from './dashboard-test-helpers';
|
||||
|
||||
// Record video regardless of pass/fail (before/after clips).
|
||||
testWithAssets.use({ video: 'on' });
|
||||
@@ -77,7 +72,8 @@ testWithAssets(
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartId = await extractIdFromResponse(chartResp);
|
||||
const chart = await chartResp.json();
|
||||
const chartId: number = chart.id ?? chart.result?.id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const positionJson = buildSingleRowDashboardLayout([
|
||||
@@ -90,21 +86,33 @@ testWithAssets(
|
||||
]);
|
||||
|
||||
// 2. json_metadata: one dashboard filter + one Display Control.
|
||||
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const customizationId = `CHART_CUSTOMIZATION-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10)}`;
|
||||
|
||||
const jsonMetadata = buildFilterJsonMetadata({
|
||||
chartsInScope: [chartId],
|
||||
nativeFilters: [
|
||||
buildSelectFilter({
|
||||
datasetId,
|
||||
column: FILTER_COLUMN,
|
||||
chartsInScope: [chartId],
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: [
|
||||
{
|
||||
id: filterId,
|
||||
name: 'Gender',
|
||||
}),
|
||||
filterType: 'filter_select',
|
||||
type: 'NATIVE_FILTER',
|
||||
targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
|
||||
controlValues: {
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
inverseSelection: false,
|
||||
searchAllOptions: false,
|
||||
},
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
chartCustomizations: [
|
||||
chart_customization_config: [
|
||||
{
|
||||
id: customizationId,
|
||||
type: 'CHART_CUSTOMIZATION',
|
||||
@@ -119,7 +127,13 @@ testWithAssets(
|
||||
removed: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
chart_configuration: {},
|
||||
cross_filters_enabled: false,
|
||||
global_chart_configuration: {
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
};
|
||||
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `display_control_repro_${Date.now()}`,
|
||||
@@ -128,7 +142,8 @@ testWithAssets(
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashboardId = await extractIdFromResponse(dashResp);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
|
||||
@@ -140,22 +155,14 @@ testWithAssets(
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId);
|
||||
await dashboardPage.waitForLoad({ timeout: 30000 });
|
||||
|
||||
/**
|
||||
* Best-effort settle after each mutation. Every assertion below targets the
|
||||
* filter bar rather than chart content, so a chart that is still querying
|
||||
* must not fail the test — but giving charts a chance to finish keeps the
|
||||
* bar from being re-rendered underneath the assertions.
|
||||
*/
|
||||
const settleCharts = () =>
|
||||
dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
|
||||
|
||||
await settleCharts();
|
||||
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
|
||||
const filterBar = await dashboardPage.waitForFilterBar();
|
||||
|
||||
// Both the Gender filter and the Time grain Display Control should render.
|
||||
await expect(dashboardPage.getDisplayControlsHeader()).toBeVisible();
|
||||
await expect(dashboardPage.getDisplayControl('Time grain')).toBeVisible();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('STEP 1: Display control "Time grain" is present in the bar.');
|
||||
await shot('01-initial-bar');
|
||||
|
||||
// 4. Open the filters config modal via the settings gear.
|
||||
@@ -165,26 +172,40 @@ testWithAssets(
|
||||
// 5. Delete the "Time grain" Display Control in the modal sidebar.
|
||||
await modal.removeDisplayControl('Time grain');
|
||||
await expect(modal.getRemovedMarker()).toBeVisible();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('STEP 2: Display control marked (Removed) in modal.');
|
||||
await shot('03-modal-removed');
|
||||
|
||||
// 6. Save the modal.
|
||||
await modal.clickSave();
|
||||
await modal.waitForHidden({ timeout: 20000 });
|
||||
await settleCharts();
|
||||
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
|
||||
await shot('04-after-save');
|
||||
|
||||
const goneAfterSave = await dashboardPage
|
||||
.getDisplayControl('Time grain')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`STEP 3: After save, "Time grain" visible in bar = ${goneAfterSave}`,
|
||||
);
|
||||
|
||||
// 7. Click Apply Filters.
|
||||
await filterBar.applyIfEnabled();
|
||||
await settleCharts();
|
||||
/**
|
||||
* Hold before asserting. The bug this guards against is the control coming
|
||||
* *back*, and `toHaveCount(0)` passes the instant it is absent — so without
|
||||
* a pause the assertion can sample the gap before the re-render and pass on
|
||||
* a dashboard that is about to fail. The wait is the reappearance window.
|
||||
*/
|
||||
await dashboardPage.waitForChartsToLoad({ timeout: 8000 }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await shot('05-after-apply');
|
||||
|
||||
const reappeared = await dashboardPage
|
||||
.getDisplayControl('Time grain')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`STEP 4: After Apply Filters, "Time grain" reappeared = ${reappeared}`,
|
||||
);
|
||||
|
||||
// The deleted Display Control must stay gone.
|
||||
await expect(
|
||||
dashboardPage.getDisplayControl('Time grain'),
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dashboard edit-mode component tests — these replace the deprecated Cypress
|
||||
* spec cypress-base/cypress/e2e/dashboard/editmode.test.ts, deleted in the same
|
||||
* change. They cover the chart/markdown drag-and-drop workflows that the
|
||||
* upstream Cypress notes flagged as the one part of edit mode that genuinely
|
||||
* requires E2E coverage ("Chart drag/drop functionality requires true E2E
|
||||
* testing"). The grid uses react-dnd with the HTML5 backend, so drags are
|
||||
* driven by synthetic native drag events (see helpers/dnd.ts).
|
||||
*
|
||||
* Coverage here is a superset of the Cypress spec, which by the time of the
|
||||
* migration held a single "should add charts" test — its "Color consistency"
|
||||
* block had already been dropped upstream as permanently skipped (it read
|
||||
* per-series colors off an `.nv-legend-symbol` SVG `fill` that ECharts, which
|
||||
* renders to <canvas>, no longer produces). That color-precedence logic is
|
||||
* covered by Jest/RTL, not by E2E.
|
||||
*/
|
||||
|
||||
import {
|
||||
testWithAssets,
|
||||
expect,
|
||||
type TestAssets,
|
||||
} from '../../helpers/fixtures';
|
||||
import { apiPostChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { createTestDashboard } from './dashboard-test-helpers';
|
||||
import type { Page, TestInfo } from '@playwright/test';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
/**
|
||||
* How long one click on the markdown component gets to bring up the ace editor
|
||||
* before the retry loop tries again, and how long the whole loop gets. The
|
||||
* per-attempt budget is deliberately short: the failure mode is a swallowed
|
||||
* click, and retrying is cheaper than waiting out the full budget once.
|
||||
*/
|
||||
const MARKDOWN_EDIT_ATTEMPT_TIMEOUT = 2000;
|
||||
const MARKDOWN_EDIT_TOTAL_TIMEOUT = 20000;
|
||||
|
||||
/** Downward drag distance for the resize assertion — several grid rows. */
|
||||
const RESIZE_DELTA_PX = 150;
|
||||
|
||||
/** Create a hermetic chart from birth_names, NOT placed on any dashboard. */
|
||||
async function createChart(
|
||||
page: Page,
|
||||
testAssets: TestAssets,
|
||||
testInfo: TestInfo,
|
||||
): Promise<string> {
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const sliceName = `edit_mode_chart_${Date.now()}_${testInfo.parallelIndex}`;
|
||||
const resp = await apiPostChart(page, {
|
||||
slice_name: sliceName,
|
||||
viz_type: 'big_number_total',
|
||||
datasource_id: dataset.id,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify({
|
||||
datasource: `${dataset.id}__table`,
|
||||
viz_type: 'big_number_total',
|
||||
metric: 'count',
|
||||
}),
|
||||
});
|
||||
expect(resp.ok()).toBe(true);
|
||||
testAssets.trackChart(await extractIdFromResponse(resp));
|
||||
return sliceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the empty published dashboard every test in this file starts from,
|
||||
* open it, and enter edit mode. Returns the page object positioned on the
|
||||
* builder, ready for a drag.
|
||||
*/
|
||||
async function openEmptyDashboardInEditMode(
|
||||
page: Page,
|
||||
testAssets: TestAssets,
|
||||
testInfo: TestInfo,
|
||||
): Promise<DashboardPage> {
|
||||
const { id } = await createTestDashboard(page, testAssets, testInfo, {
|
||||
prefix: 'edit_mode',
|
||||
published: true,
|
||||
});
|
||||
|
||||
const dashboard = new DashboardPage(page);
|
||||
await dashboard.gotoById(id);
|
||||
await dashboard.waitForLoad();
|
||||
await dashboard.enterEditMode();
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'edit mode: add a chart to the dashboard via drag-and-drop',
|
||||
async ({ page, testAssets }, testInfo) => {
|
||||
const sliceName = await createChart(page, testAssets, testInfo);
|
||||
const dashboard = await openEmptyDashboardInEditMode(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
);
|
||||
|
||||
await expect(dashboard.getChartHolders()).toHaveCount(0);
|
||||
await dashboard.addChartByName(sliceName);
|
||||
await expect(dashboard.getChartHolders()).toHaveCount(1);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'edit mode: remove an added chart from the dashboard',
|
||||
async ({ page, testAssets }, testInfo) => {
|
||||
const sliceName = await createChart(page, testAssets, testInfo);
|
||||
const dashboard = await openEmptyDashboardInEditMode(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
);
|
||||
|
||||
await dashboard.addChartByName(sliceName);
|
||||
await expect(dashboard.getChartHolders()).toHaveCount(1);
|
||||
|
||||
await dashboard.deleteChartHolder();
|
||||
await expect(dashboard.getChartHolders()).toHaveCount(0);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'edit mode: add a markdown component via drag-and-drop',
|
||||
async ({ page, testAssets }, testInfo) => {
|
||||
// Heaviest edit-mode flow (drag + ace edit + commit + mouse resize); give it
|
||||
// extra headroom so it stays reliable when the suite runs in parallel.
|
||||
testWithAssets.slow();
|
||||
const dashboard = await openEmptyDashboardInEditMode(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
);
|
||||
|
||||
await dashboard.addLayoutElement('Text / Markdown');
|
||||
const editor = dashboard.getMarkdownEditors().first();
|
||||
await expect(editor).toBeVisible();
|
||||
|
||||
// Enter edit mode by focusing the component. The markdown enters edit on a
|
||||
// document-level focus handler attached after mount, so a single early click
|
||||
// can be missed under load; retry until the ace editor appears. Click the
|
||||
// rendered "Header 1" heading element specifically (never the trailing
|
||||
// hyperlink in the default content), so a stray click can't navigate away.
|
||||
const aceContent = dashboard.getMarkdownAceContent(editor);
|
||||
const heading = editor.locator('h1', { hasText: 'Header 1' });
|
||||
await expect(async () => {
|
||||
if (await aceContent.isVisible()) return;
|
||||
await heading.click();
|
||||
await expect(aceContent).toBeVisible({
|
||||
timeout: MARKDOWN_EDIT_ATTEMPT_TIMEOUT,
|
||||
});
|
||||
}).toPass({ timeout: MARKDOWN_EDIT_TOTAL_TIMEOUT });
|
||||
await expect(aceContent).toContainText('Header 1');
|
||||
await expect(aceContent).toContainText('markdown formatting');
|
||||
|
||||
// Replace the content and confirm the edit is reflected.
|
||||
const aceInput = dashboard.getMarkdownAceInput(editor);
|
||||
await aceInput.press('ControlOrMeta+a');
|
||||
await aceInput.press('Delete');
|
||||
await aceInput.pressSequentially('Test resize');
|
||||
await expect(aceContent).toContainText('Test resize');
|
||||
|
||||
// Commit by clicking outside the component. Ace unmounting is what proves
|
||||
// the component left its editing state — the wrapper contains "Test resize"
|
||||
// either way, since ace holds that text before the click too.
|
||||
await dashboard.blurToDashboardTitle();
|
||||
await expect(aceContent).toBeHidden();
|
||||
await expect(editor).toContainText('Test resize');
|
||||
|
||||
// Resize via the bottom handle and confirm the component grew taller.
|
||||
const { heightBefore, heightAfter } = await dashboard.resizeComponent(
|
||||
editor,
|
||||
RESIZE_DELTA_PX,
|
||||
);
|
||||
expect(heightAfter).toBeGreaterThan(heightBefore);
|
||||
},
|
||||
);
|
||||
@@ -39,26 +39,26 @@ import {
|
||||
apiPostDashboard,
|
||||
buildSingleRowDashboardLayout,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import {
|
||||
buildFilterJsonMetadata,
|
||||
buildSelectFilter,
|
||||
} from './dashboard-test-helpers';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const FILTER_COLUMN = 'gender';
|
||||
const FILTER_VALUE = 'boy';
|
||||
|
||||
async function findDatasetIdByName(page: any, name: string): Promise<number> {
|
||||
const query = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
|
||||
const resp = await page.request.get(`api/v1/dataset/?q=${query}`);
|
||||
const body = await resp.json();
|
||||
if (!body.result?.length) {
|
||||
throw new Error(`Dataset ${name} not found`);
|
||||
}
|
||||
return body.result[0].id;
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'Mixed chart applies dashboard filter to both queries (#29519)',
|
||||
async ({ page, testAssets }) => {
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const datasetId = dataset.id;
|
||||
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
|
||||
|
||||
const chartParams = {
|
||||
datasource: `${datasetId}__table`,
|
||||
@@ -86,9 +86,10 @@ testWithAssets(
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartId = await extractIdFromResponse(chartResp);
|
||||
const chartId: number = (await chartResp.json()).id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const positionJson = buildSingleRowDashboardLayout([
|
||||
{
|
||||
id: chartId,
|
||||
@@ -97,20 +98,39 @@ testWithAssets(
|
||||
height: 60,
|
||||
},
|
||||
]);
|
||||
// Preselect the filter value so it is already applied on the dashboard's
|
||||
// first chart-data request — that request is what the assertions inspect.
|
||||
const jsonMetadata = buildFilterJsonMetadata({
|
||||
chartsInScope: [chartId],
|
||||
nativeFilters: [
|
||||
buildSelectFilter({
|
||||
datasetId,
|
||||
column: FILTER_COLUMN,
|
||||
chartsInScope: [chartId],
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: [
|
||||
{
|
||||
id: filterId,
|
||||
name: 'Gender',
|
||||
defaultValue: FILTER_VALUE,
|
||||
}),
|
||||
filterType: 'filter_select',
|
||||
type: 'NATIVE_FILTER',
|
||||
targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
|
||||
controlValues: {
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
inverseSelection: false,
|
||||
searchAllOptions: false,
|
||||
},
|
||||
defaultDataMask: {
|
||||
filterState: { value: [FILTER_VALUE] },
|
||||
extraFormData: {
|
||||
filters: [{ col: FILTER_COLUMN, op: 'IN', val: [FILTER_VALUE] }],
|
||||
},
|
||||
},
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
});
|
||||
chart_configuration: {},
|
||||
cross_filters_enabled: false,
|
||||
global_chart_configuration: {
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
};
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `mixed_filter_repro_${Date.now()}`,
|
||||
published: true,
|
||||
@@ -118,7 +138,8 @@ testWithAssets(
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashboardId = await extractIdFromResponse(dashResp);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
await apiPut(page, `api/v1/chart/${chartId}`, {
|
||||
|
||||
@@ -128,15 +128,16 @@ test('non-admin user can view a themed dashboard without 403 or infinite spinner
|
||||
|
||||
// --- NON-ADMIN USER PHASE (page has no cached auth via test.use) ---
|
||||
|
||||
// 4. Instrument network: track any /api/v1/theme/ request, with its status.
|
||||
// Recording the status rather than asserting on a separate 403-only array
|
||||
// keeps the diagnostic — a failure prints whether the calls were forbidden
|
||||
// or merely unexpected — without a second, subsumed assertion.
|
||||
// 4. Instrument network: track any /api/v1/theme/ requests and 403 responses
|
||||
const themeApiRequests: string[] = [];
|
||||
const forbiddenResponses: string[] = [];
|
||||
page.on('response', response => {
|
||||
const url = response.url();
|
||||
if (url.includes('/api/v1/theme/')) {
|
||||
themeApiRequests.push(`${response.status()} ${url}`);
|
||||
themeApiRequests.push(url);
|
||||
}
|
||||
if (response.status() === 403 && url.includes('/api/v1/theme/')) {
|
||||
forbiddenResponses.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,19 +152,14 @@ test('non-admin user can view a themed dashboard without 403 or infinite spinner
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId!);
|
||||
|
||||
// 7. Assert dashboard fully loads (not stuck on infinite spinner).
|
||||
// The dashboard is created with no position_json, so its grid renders
|
||||
// empty — there is no chart to wait for, only the grid itself.
|
||||
// 7. Assert dashboard fully loads (not stuck on infinite spinner)
|
||||
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
await dashboardPage.waitForGridToLoad();
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
// 8. A non-admin must render the themed dashboard without ever calling the
|
||||
// theme API — theme data rides along on the dashboard response, and the
|
||||
// endpoint itself is admin-only, so any call here would 403 and break them.
|
||||
expect(
|
||||
themeApiRequests,
|
||||
'Non-admin dashboard load must not call the theme API',
|
||||
).toHaveLength(0);
|
||||
// 8. Assert no /api/v1/theme/ requests were made (theme data comes from dashboard response)
|
||||
expect(themeApiRequests).toHaveLength(0);
|
||||
// Assert no 403 responses on /api/v1/theme/ (scoped to avoid login/unrelated 403 noise)
|
||||
expect(forbiddenResponses).toHaveLength(0);
|
||||
} finally {
|
||||
// Cleanup: delete test resources using admin context
|
||||
if (dashboardId) {
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
ENDPOINTS,
|
||||
} from '../../helpers/api/dataset';
|
||||
import { createTestDataset } from './dataset-test-helpers';
|
||||
import { isFeatureEnabled } from '../../helpers/featureFlags';
|
||||
import {
|
||||
waitForGet,
|
||||
waitForPost,
|
||||
@@ -121,21 +120,19 @@ test('should delete a dataset with confirmation', async ({
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
await deleteModal.waitForVisible();
|
||||
|
||||
// Confirm: types "DELETE" while the modal is destructive, and goes straight
|
||||
// through once SOFT_DELETE makes it a recoverable archive instead.
|
||||
await deleteModal.confirmDeletion();
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears with correct message. The copy names what
|
||||
// actually happened, so it tracks the mode: archiving is not deleting, and
|
||||
// a toast saying otherwise would misreport a recoverable action as final.
|
||||
// Verify success toast appears with correct message.
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
await expect(toast.getMessage()).toContainText(
|
||||
(await isFeatureEnabled(page, 'SOFT_DELETE')) ? 'Archived' : 'Deleted',
|
||||
);
|
||||
await expect(toast.getMessage()).toContainText('Deleted');
|
||||
|
||||
// Verify dataset is removed from list (deleted rows are removed from the DOM, so assert count rather than visibility)
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0, {
|
||||
@@ -434,9 +431,11 @@ test('should bulk delete multiple datasets', async ({
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
await deleteModal.waitForVisible();
|
||||
|
||||
// Confirm: types "DELETE" while the modal is destructive, and goes straight
|
||||
// through once SOFT_DELETE makes it a recoverable archive instead.
|
||||
await deleteModal.confirmDeletion();
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
@@ -38,14 +38,6 @@
|
||||
*
|
||||
* NOTE: the embedded suite only runs when the embedded SDK bundle is built and
|
||||
* INCLUDE_EMBEDDED=true (CI sets both). It is skipped otherwise.
|
||||
*
|
||||
* NOTE: the embedded project runs with admin storageState, and the session
|
||||
* cookie the iframe ends up with is racy: the /embedded/<uuid> response
|
||||
* rotates it to an anonymous session while the SDK's parallel csrf and
|
||||
* guest-token fetches rewrite the admin one, so chart data requests are
|
||||
* evaluated as either admin or guest depending on which response lands last.
|
||||
* Fixture charts must therefore hold up under GUEST evaluation — see the
|
||||
* query_context note below — or the suite only passes when admin wins.
|
||||
*/
|
||||
import { test, expect, Browser, BrowserContext, Page } from '@playwright/test';
|
||||
import { createServer, IncomingMessage, ServerResponse, Server } from 'http';
|
||||
@@ -190,43 +182,12 @@ test.describe('Embedded Pivot Table collapse state (#33406)', () => {
|
||||
row_limit: 1000,
|
||||
order_desc: true,
|
||||
};
|
||||
// Charts saved through Explore always persist a query_context alongside
|
||||
// params. Store one here too: guest (embedded) requests are validated
|
||||
// against the stored chart, and a params-only chart makes the guest
|
||||
// payload look tampered (its query `columns` aren't found on the chart),
|
||||
// failing every chart data request with a 403.
|
||||
const queryContext = {
|
||||
datasource: { id: datasetId, type: 'table' },
|
||||
force: false,
|
||||
queries: [
|
||||
{
|
||||
filters: [],
|
||||
extras: { having: '', where: '' },
|
||||
applied_time_extras: {},
|
||||
columns: ['state', 'name'],
|
||||
metrics: ['count'],
|
||||
orderby: [['count', false]],
|
||||
annotation_layers: [],
|
||||
row_limit: 1000,
|
||||
series_limit: 0,
|
||||
order_desc: true,
|
||||
url_params: {},
|
||||
custom_params: {},
|
||||
custom_form_data: {},
|
||||
},
|
||||
],
|
||||
form_data: params,
|
||||
result_format: 'json',
|
||||
result_type: 'full',
|
||||
};
|
||||
const chartResp = await apiPost(setupPage, 'api/v1/chart/', {
|
||||
slice_name: `pivot_collapse_repro_${Date.now()}`,
|
||||
viz_type: 'pivot_table_v2',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(params),
|
||||
query_context: JSON.stringify(queryContext),
|
||||
query_context_generation: true,
|
||||
});
|
||||
chartId = (await chartResp.json()).id;
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
* unsafe to run on parallel workers, and left version records behind that no
|
||||
* revert could remove — version history being append-only is the point.
|
||||
*/
|
||||
import { Page } from '@playwright/test';
|
||||
import { APIRequestContext, Page } from '@playwright/test';
|
||||
import rison from 'rison';
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiGet } from '../../helpers/api/requests';
|
||||
@@ -55,15 +55,17 @@ const OPAQUE_ID =
|
||||
* `can_overwrite` gate derived from them) live in the Subject id space, not
|
||||
* the user id space.
|
||||
*/
|
||||
async function currentUserSubjectId(page: Page): Promise<number> {
|
||||
const meRes = await apiGet(page, 'api/v1/me/');
|
||||
async function currentUserSubjectId(
|
||||
request: APIRequestContext,
|
||||
): Promise<number> {
|
||||
const meRes = await request.get('/api/v1/me/');
|
||||
expect(meRes.ok(), 'current user request').toBeTruthy();
|
||||
const userId = (await meRes.json()).result.id;
|
||||
|
||||
const q = encodeURIComponent(
|
||||
`(filters:!((col:user_id,opr:eq,value:${userId})))`,
|
||||
);
|
||||
const res = await apiGet(page, `api/v1/security/subject/?q=${q}`);
|
||||
const res = await request.get(`/api/v1/security/subject/?q=${q}`);
|
||||
expect(res.ok(), 'subject lookup request').toBeTruthy();
|
||||
const subjects = (await res.json()).result;
|
||||
expect(
|
||||
@@ -139,7 +141,7 @@ testWithAssets(
|
||||
// without editors.
|
||||
// Two renames: the first edit on an as-yet-untracked chart collapses into
|
||||
// "first tracked save"; the second is a normal descriptive save.
|
||||
const adminSubjectId = await currentUserSubjectId(page);
|
||||
const adminSubjectId = await currentUserSubjectId(page.request);
|
||||
await apiPutChart(page, chartId, {
|
||||
editors: [adminSubjectId],
|
||||
slice_name: `${baseName} ·vh1`,
|
||||
|
||||
@@ -17,10 +17,10 @@ specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
## @superset-ui/plugin-chart-calendar
|
||||
## @superset-ui/legacy-plugin-chart-calendar
|
||||
|
||||
[](https://www.npmjs.com/package/@superset-ui/plugin-chart-calendar)
|
||||
[](https://libraries.io/npm/@superset-ui%2Fplugin-chart-calendar)
|
||||
[](https://www.npmjs.com/package/@superset-ui/legacy-plugin-chart-calendar)
|
||||
[](https://libraries.io/npm/@superset-ui%2Flegacy-plugin-chart-calendar)
|
||||
|
||||
This plugin provides Calendar Heatmap for Superset.
|
||||
|
||||
@@ -30,7 +30,7 @@ Configure `key`, which can be any `string`, and register the plugin. This `key`
|
||||
lookup this chart throughout the app.
|
||||
|
||||
```js
|
||||
import CalendarChartPlugin from '@superset-ui/plugin-chart-calendar';
|
||||
import CalendarChartPlugin from '@superset-ui/legacy-plugin-chart-calendar';
|
||||
|
||||
new CalendarChartPlugin().configure({ key: 'calendar' }).register();
|
||||
```
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@superset-ui/plugin-chart-calendar",
|
||||
"name": "@superset-ui/legacy-plugin-chart-calendar",
|
||||
"version": "0.20.3",
|
||||
"description": "Superset Chart - Calendar Heatmap",
|
||||
"description": "Superset Legacy Chart - Calendar Heatmap",
|
||||
"keywords": [
|
||||
"superset"
|
||||
],
|
||||
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-calendar#readme",
|
||||
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/legacy-plugin-chart-calendar#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/apache/superset/issues"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/apache/superset.git",
|
||||
"directory": "superset-frontend/plugins/plugin-chart-calendar"
|
||||
"directory": "superset-frontend/plugins/legacy-plugin-chart-calendar"
|
||||
},
|
||||
"files": [
|
||||
"esm",
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -43,13 +43,13 @@ const metadata = new ChartMetadata({
|
||||
],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
export default class CalendarChartPlugin extends ChartPlugin {
|
||||
constructor() {
|
||||
super({
|
||||
loadChart: () => import('./ReactCalendar'),
|
||||
loadBuildQuery: () => import('./buildQuery'),
|
||||
metadata,
|
||||
transformProps,
|
||||
controlPanel,
|
||||
@@ -18,14 +18,14 @@
|
||||
*/
|
||||
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import CalendarChartPlugin from '@superset-ui/plugin-chart-calendar';
|
||||
import CalendarChartPlugin from '@superset-ui/legacy-plugin-chart-calendar';
|
||||
import data from './data';
|
||||
import { dummyDatasource, withResizableChartDemo } from '@storybook-shared';
|
||||
|
||||
new CalendarChartPlugin().configure({ key: 'calendar' }).register();
|
||||
|
||||
export default {
|
||||
title: 'Chart Plugins/plugin-chart-calendar',
|
||||
title: 'Legacy Chart Plugins/legacy-plugin-chart-calendar',
|
||||
decorators: [withResizableChartDemo],
|
||||
args: {
|
||||
cellSize: 10,
|
||||
@@ -17,15 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ChartProps,
|
||||
ensureIsArray,
|
||||
getMetricLabel,
|
||||
getNumberFormatter,
|
||||
QueryFormMetric,
|
||||
} from '@superset-ui/core';
|
||||
import { ChartProps, getNumberFormatter } from '@superset-ui/core';
|
||||
import { getFormattedUTCTime } from './utils';
|
||||
import transformData from './transformData';
|
||||
|
||||
export default function transformProps(chartProps: ChartProps) {
|
||||
const { height, formData, queriesData, datasource } = chartProps;
|
||||
@@ -51,29 +44,9 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
getFormattedUTCTime(ts, xAxisTimeFormat);
|
||||
const valueFormatter = getNumberFormatter(yAxisFormat);
|
||||
|
||||
// The legacy explore_json endpoint computed the per-metric value maps
|
||||
// and domain range server-side; v1 responses arrive as flat records.
|
||||
const {
|
||||
data: rawData,
|
||||
from_dttm: fromDttm,
|
||||
to_dttm: toDttm,
|
||||
} = queriesData[0];
|
||||
const data = Array.isArray(rawData)
|
||||
? transformData(
|
||||
rawData,
|
||||
ensureIsArray(formData.metrics as QueryFormMetric[]).map(
|
||||
getMetricLabel,
|
||||
),
|
||||
fromDttm,
|
||||
toDttm,
|
||||
domainGranularity,
|
||||
subdomainGranularity,
|
||||
)
|
||||
: rawData;
|
||||
|
||||
return {
|
||||
height,
|
||||
data,
|
||||
data: queriesData[0].data,
|
||||
cellPadding,
|
||||
cellRadius,
|
||||
cellSize,
|
||||
@@ -17,10 +17,10 @@ specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
## @superset-ui/plugin-chart-chord
|
||||
## @superset-ui/legacy-plugin-chart-chord
|
||||
|
||||
[](https://www.npmjs.com/package/@superset-ui/plugin-chart-chord)
|
||||
[](https://libraries.io/npm/@superset-ui%2Fplugin-chart-chord)
|
||||
[](https://www.npmjs.com/package/@superset-ui/legacy-plugin-chart-chord)
|
||||
[](https://libraries.io/npm/@superset-ui%2Flegacy-plugin-chart-chord)
|
||||
|
||||
This plugin provides Chord Diagram for Superset.
|
||||
|
||||
@@ -30,7 +30,7 @@ Configure `key`, which can be any `string`, and register the plugin. This `key`
|
||||
lookup this chart throughout the app.
|
||||
|
||||
```js
|
||||
import ChordChartPlugin from '@superset-ui/plugin-chart-chord';
|
||||
import ChordChartPlugin from '@superset-ui/legacy-plugin-chart-chord';
|
||||
|
||||
new ChordChartPlugin().configure({ key: 'chord' }).register();
|
||||
```
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@superset-ui/plugin-chart-chord",
|
||||
"name": "@superset-ui/legacy-plugin-chart-chord",
|
||||
"version": "0.20.3",
|
||||
"description": "Superset Chart - Chord Diagram",
|
||||
"description": "Superset Legacy Chart - Chord Diagram",
|
||||
"keywords": [
|
||||
"superset"
|
||||
],
|
||||
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-chord#readme",
|
||||
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/legacy-plugin-chart-chord#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/apache/superset/issues"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/apache/superset.git",
|
||||
"directory": "superset-frontend/plugins/plugin-chart-chord"
|
||||
"directory": "superset-frontend/plugins/legacy-plugin-chart-chord"
|
||||
},
|
||||
"files": [
|
||||
"esm",
|
||||
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
@@ -39,16 +39,16 @@ const metadata = new ChartMetadata({
|
||||
},
|
||||
],
|
||||
name: t('Chord Diagram'),
|
||||
tags: [t('Circular'), t('Proportional'), t('Relational')],
|
||||
tags: [t('Circular'), t('Legacy'), t('Proportional'), t('Relational')],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
export default class ChordChartPlugin extends ChartPlugin {
|
||||
constructor() {
|
||||
super({
|
||||
loadChart: () => import('./ReactChord'),
|
||||
loadBuildQuery: () => import('./buildQuery'),
|
||||
metadata,
|
||||
transformProps,
|
||||
controlPanel,
|
||||
@@ -18,14 +18,14 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import ChordChartPlugin from '@superset-ui/plugin-chart-chord';
|
||||
import ChordChartPlugin from '@superset-ui/legacy-plugin-chart-chord';
|
||||
import data from './data';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
|
||||
new ChordChartPlugin().configure({ key: VizType.Chord }).register();
|
||||
|
||||
export default {
|
||||
title: 'Chart Plugins/plugin-chart-chord',
|
||||
title: 'Legacy Chart Plugins/legacy-plugin-chart-chord',
|
||||
decorators: [withResizableChartDemo],
|
||||
args: {
|
||||
colorScheme: 'd3Category10',
|
||||
@@ -16,10 +16,18 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import CountryMapChartPlugin from '../src/index';
|
||||
import { ChartProps } from '@superset-ui/core';
|
||||
|
||||
test('registers the plugin with its v1 metadata and buildQuery loader', () => {
|
||||
const plugin = new CountryMapChartPlugin();
|
||||
expect(plugin.metadata.name).toEqual('Country Map');
|
||||
expect(plugin.loadBuildQuery).toBeDefined();
|
||||
});
|
||||
export default function transformProps(chartProps: ChartProps) {
|
||||
const { width, height, formData, queriesData } = chartProps;
|
||||
const { yAxisFormat, colorScheme, sliceId } = formData;
|
||||
|
||||
return {
|
||||
colorScheme,
|
||||
data: queriesData[0].data,
|
||||
height,
|
||||
numberFormat: yAxisFormat,
|
||||
width,
|
||||
sliceId,
|
||||
};
|
||||
}
|
||||
@@ -17,10 +17,10 @@ specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
## @superset-ui/plugin-chart-country-map
|
||||
## @superset-ui/legacy-plugin-chart-country-map
|
||||
|
||||
[](https://www.npmjs.com/package/@superset-ui/plugin-chart-country-map)
|
||||
[](https://libraries.io/npm/@superset-ui%2Fplugin-chart-country-map)
|
||||
[](https://www.npmjs.com/package/@superset-ui/legacy-plugin-chart-country-map)
|
||||
[](https://libraries.io/npm/@superset-ui%2Flegacy-plugin-chart-country-map)
|
||||
|
||||
This plugin provides Country Map for Superset.
|
||||
|
||||
@@ -30,7 +30,7 @@ Configure `key`, which can be any `string`, and register the plugin. This `key`
|
||||
lookup this chart throughout the app.
|
||||
|
||||
```js
|
||||
import CountryMapChartPlugin from '@superset-ui/plugin-chart-country-map';
|
||||
import CountryMapChartPlugin from '@superset-ui/legacy-plugin-chart-country-map';
|
||||
|
||||
new CountryMapChartPlugin().configure({ key: 'country-map' }).register();
|
||||
```
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@superset-ui/plugin-chart-country-map",
|
||||
"name": "@superset-ui/legacy-plugin-chart-country-map",
|
||||
"version": "0.20.3",
|
||||
"description": "Superset Chart - Country Map",
|
||||
"description": "Superset Legacy Chart - Country Map",
|
||||
"keywords": [
|
||||
"superset"
|
||||
],
|
||||
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-country-map#readme",
|
||||
"homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/legacy-plugin-chart-country-map#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/apache/superset/issues"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/apache/superset.git",
|
||||
"directory": "superset-frontend/plugins/plugin-chart-country-map"
|
||||
"directory": "superset-frontend/plugins/legacy-plugin-chart-country-map"
|
||||
},
|
||||
"files": [
|
||||
"esm",
|
||||