Compare commits

..
Author SHA1 Message Date
Superset Dev f0f55fbed3 fix(testcontainers): also monkeypatch oceanbase_py's get_columns
Fixing has_table() alone wasn't enough: get_columns() -- the actual
column-introspection call OceanBaseEngineSpec.get_columns() needs, and
what this test's second half exercises -- has the identical
connection.execute(raw string) bug, confirmed on real CI as the same
ObjectNotExecutableError. Patched the same way, reusing has_table()
correctly since self.has_table already resolves to the earlier patch.
2026-08-28 19:46:21 -07:00
Superset Dev b4c57ae9a5 fix(testcontainers): catch the not-found error in the has_table monkeypatch
Confirmed on real CI: even with exec_driver_sql fixing the SQLAlchemy 2.0
incompatibility, has_table() still failed -- DESCRIBE on a nonexistent
table raises 1146 ("table doesn't exist") rather than returning an empty
result set, and the original method never catches that at all. It can
only ever return True; checking for a table that doesn't exist (exactly
what create_all()'s checkfirst does first) raised instead of returning
False. Catches the error and returns False, same as any other dialect's
has_table() would.
2026-08-28 19:33:55 -07:00
Superset Dev 4589a78e52 fix(testcontainers): monkeypatch oceanbase_py's has_table instead of skipping checkfirst
checkfirst=False only sidestepped create_all()'s own call to has_table();
Inspector.get_columns() (used by OceanBaseEngineSpec.get_columns(), which
the second test needs to actually exercise) calls the same broken
has_table() internally and hit the identical ObjectNotExecutableError
(confirmed on real CI). Every other raw-SQL method in the same dialect
module correctly uses connection.exec_driver_sql(...) -- this looks like
an isolated oversight in just has_table(), not a deliberate design
choice, so this monkeypatches it to do the same thing the rest of the
dialect already does, fixing the root cause for both call sites instead
of routing around one of them.
2026-08-28 19:16:33 -07:00
Superset Dev c027d8ca6e fix(testcontainers): skip create_all's checkfirst for oceanbase
oceanbase_py's has_table() -- called by create_all()'s default
checkfirst=True before creating each table -- passes a raw string
straight to Connection.execute(), which SQLAlchemy 2.0 rejects outright
(ObjectNotExecutableError, confirmed on real CI). Added an optional
checkfirst override to the shared _pagination.py helper and used it here;
safe to skip the existence check since each test gets a genuinely fresh
container.
2026-08-28 19:03:23 -07:00
Superset Dev e053b6d92d fix(testcontainers): fix oceanbase driver install, drop vertica
The oceanbase driver install step reported success but never actually
installed oceanbase_py: --no-deps applied to `-e .[oceanbase]` blocks pip
from installing anything the extras marker pulls in, including
oceanbase_py itself, not just its conflicting transitive dependency.
Installing oceanbase_py as its own standalone package instead means
--no-deps only skips *its* dependencies, which is what was actually
intended. Confirmed via a manual workflow_dispatch run (nightly_only
dialects don't run on pull_request, so this needed a manual trigger to
catch at all).

Vertica dropped from this PR: the same workflow_dispatch run found
`vertica/vertica-ce` doesn't exist on Docker Hub. The only actively
maintained official image (`opentext/vertica-k8s`) is built to run under
the Vertica Kubernetes operator's orchestration, not as a standalone
single-container database -- a bare `docker run` likely won't bootstrap
a working instance on its own. Needs real investigation before it's
worth another attempt, same as Solr/IoTDB/TDengine/Parseable/Dremio
earlier in this series.
2026-08-28 18:54:06 -07:00
Superset Dev 65532b6bdd fix(testcontainers): dispose the firebird engine before the container tears down
Both tests passed on CI, but the job still failed: firebird-driver
registers its own atexit handler that talks to the Firebird subsystem to
shut it down cleanly, and it fired after the container was already gone,
segfaulting (exit code 139) rather than raising a catchable error.
Disposing the engine while the server is still up lets the driver close
out normally, so the later atexit call has nothing left to talk to.
2026-08-28 18:42:23 -07:00
Superset Dev 05e8d24d9d feat(ci): expand testcontainers coverage to databend, risingwave, firebird, ydb, oceanbase, vertica
Stacked on feat/testcontainers-nightly-only-gating. All six extras already
existed in pyproject.toml. oceanbase and vertica run nightly_only: true
(heavy first-boot and a ~12GB RAM floor, respectively), so they don't run
per-PR; databend/risingwave/firebird/ydb run on every PR like the rest of
this suite.

- oceanbase_py pins sqlalchemy-utils<0.39, which conflicts outright with
  Superset's own sqlalchemy-utils==0.42.1 pin -- kept out of the baseline
  dev install (same reason as db2's ibm-db-sa) and installed on demand,
  --no-deps, only for its own CI leg (it never actually imports
  sqlalchemy_utils itself, so the version mismatch is inert at runtime).
- databend: connects to the local standalone image's builtin `root` user
  (no password) with sslmode=disable, since Superset's default
  encryption_parameters assume TLS the local image doesn't have.
- risingwave: RisingWave's storage engine checkpoints asynchronously --
  a SELECT immediately after INSERT can see zero rows without an explicit
  FLUSH (confirmed on a real instance). Uses the shared _pagination.py
  helper's after_insert hook (originally added for CrateDB) to do that.
- firebird: sqlalchemy-firebird's driver is a pure-Python ctypes wrapper
  (py3-none-any wheel, confirmed by downloading it directly) that
  dynamically loads the native libfbclient from the host rather than
  bundling it -- CI installs that system package on demand. Also confirms
  in the test docstring that FirebirdEngineSpec's `limit_method =
  LimitMethod.FETCH_MANY` (comment: "uses FIRST to limit") is stale
  against the modern driver, which compiles real ROWS-based pagination.
- ydb: needed three real fixes to make a generic DockerContainer usable
  at all. (1) YDB's gRPC client does endpoint discovery and reconnects to
  whatever the server reports, which by default is the container's own
  internal Docker hostname -- fixed by binding the same port on the host
  as inside the container and advertising "localhost" as the container's
  own hostname, so the discovered endpoint is actually reachable. (2) The
  gRPC port opens before storage pools are fully initialized, so an early
  CREATE TABLE fails; the fixture retries a real metadata.create_all()
  probe rather than trusting the open port. (3) YDB rejects DDL inside an
  explicit transaction ("Scheme operations cannot be executed inside
  transaction") -- confirmed this only affects a raw text("CREATE
  TABLE..."), not metadata.create_all()'s own DDL execution path, which
  already does the right thing.
2026-08-28 18:35:55 -07:00
Superset Dev fec70404c4 fix(ci): move nightly_only gating from job-level to step-level if
A job-level `if:` can't reference `matrix` at all -- only github/inputs/
needs/vars contexts are available there, confirmed by actionlint and by
this exact commit's own CI run failing outright with "This run likely
failed because of a workflow file issue" (zero jobs registered). Moves
the same condition onto the step that actually runs the tests instead,
where matrix access is already used successfully by the existing db2
install step.
2026-08-28 18:35:19 -07:00
Superset Dev 72c6f95053 feat(ci): add nightly-only opt-out for heavy testcontainers dialects
A future dialect whose image is too heavy for per-PR CI (a multi-service
cluster, a many-GB image, a slow licensed installer) can set
nightly_only: true on its matrix entry to run only on the cron or a
manual workflow_dispatch, never on pull_request. No existing dialect
uses it yet -- this just lays the groundwork for candidates like SAP
HANA, Teradata, or Apache Druid.
2026-08-28 16:19:18 -07:00
Superset Dev d46e7986a2 fix(testcontainers): retry a real create-table-and-insert probe for starrocks readiness
The FE's query port accepts connections, and can even run metadata
statements like CREATE DATABASE, before the BE (execution backend) has
registered with it -- an actual CREATE TABLE/INSERT then fails with
"Backend node not found. Check if any backend node is down." (confirmed
on real CI). Replace the bare CREATE DATABASE readiness check with a
throwaway create-table-and-insert probe that exercises the exact
operations the tests below need.
2026-08-28 10:47:31 -07:00
Superset Dev 889d2a6a22 feat(ci): expand testcontainers coverage to postgres, mysql, clickhouse, starrocks
Stacked on feat/testcontainers-more-dialects. All four extras already
existed in pyproject.toml, so this only wires up tests -- no new
optional-dependency groups needed.

- postgres/mysql: straight copies of the timescaledb/mariadb pattern
  respectively, pointed at vanilla images instead of a fork/extension.
- clickhouse: connects over the container's HTTP port (8123), matching
  clickhouse-connect (Superset's driver), not the native TCP port (9000)
  the container's own docstring example uses. ClickHouse has no real
  primary-key concept and clickhouse-connect's DDL compiler rejects
  CREATE TABLE without an explicit engine, so _pagination.py gained an
  optional extra_table_args hook to pass MergeTree(order_by=...). Also
  works around a pre-existing quirk in db_engine_specs/clickhouse.py:
  its module-level type-formatting setup dereferences current_app.config,
  so importing it outside a Flask app context raises RuntimeError --
  tests/unit_tests/db_engine_specs/test_clickhouse.py already works
  around this with per-test local imports, but that suite also benefits
  from an autouse app_context fixture this suite doesn't have, so this
  test pushes one explicitly around the one-time import.
- starrocks: no dedicated testcontainers module, so a generic
  DockerContainer against the official allin1-ubuntu image (FE+BE in one
  container). Not verified locally (multiple-GB image, skipped to keep
  local Docker load low per session guidance); the fixture retries its
  first connection since the query port can accept TCP before StarRocks'
  query engine is fully initialized.
2026-08-28 10:47:31 -07:00
Superset Dev 75044575a1 fix(testcontainers): avoid table-qualified columns in mongodb pagination test
The previous fix (positional row access) only masked the symptom.
Root cause: SQLAlchemy always qualifies a Table-bound column as
"pilot_pagination.id" once there's a FROM clause, but pymongosql's
projection builder takes that qualified text completely literally as a
MongoDB field path -- {"pilot_pagination.id": 1} reads a *nested* field
under a top-level "pilot_pagination" key, which doesn't exist on these
flat documents, silently projecting None for every row instead of
raising (confirmed via pymongosql/sql/query_handler.py's
_extract_field_and_alias, which uses the raw expression text verbatim
as the Mongo field key regardless of any AS alias).

A bare column()/table() pair (not bound to each other via a real Table
object) compiles to an unqualified "id" reference, which resolves
correctly, while still exercising the dialect's own LIMIT/OFFSET
compilation through a real Core select() -- this test's actual intent.
2026-08-28 10:47:11 -07:00
Superset Dev 4028ff1e05 fix(testcontainers): use positional row access in mongodb pagination test
text() has no static column metadata of its own -- the row's key comes
entirely from whatever pymongosql's DBAPI cursor reports for the raw
compiled SQL string, which turned out to be the qualified
"pilot_pagination.id" (matching the SELECT list's column reference), not
the bare "id" that attribute access expected. Reproduced by compiling the
exact statement offline and inspecting text()'s (empty) column metadata;
confirmed the AttributeError on real CI runs of this branch and #43640.
2026-08-28 10:38:11 -07:00
rusackasandClaude Opus 4.8 67a193f1d9 fix(testcontainers): stop third-party dialect entry points from leaking global compiler state
sqlalchemy-monetdb mutates SQLAlchemy's shared, process-global
compiler.OPERATORS mapping in place on import instead of subclassing it
(OPERATORS = compiler.OPERATORS; OPERATORS[operators.ne] = " <> "). Once
Superset's own get_available_engine_specs() enumerates the "monetdb"
sqlalchemy.dialects entry point to build the "available databases" list
(which happens on every app boot, not just when MonetDB is actually
used), that changes `!=` rendering to `<>` for every dialect for the
rest of the process -- breaking test_where_operators identically across
the test-mysql/test-postgres/test-sqlite CI jobs, since installing the
monetdb extra now pulls sqlalchemy-monetdb into their shared dev
requirements.

Snapshot/restore sqlalchemy.sql.compiler.OPERATORS around each
third-party dialect entry point load so a misbehaving connector can't
leak global compiler state just because it was enumerated here.

Also fix the new mongodb testcontainers pagination test: pymongosql's
SQL-to-Mongo AST parser reads LIMIT/OFFSET off the compiled SQL text
ahead of parameter substitution, so a bound `LIMIT ?`/`OFFSET ?`
placeholder is silently dropped. Compile with literal_binds=True instead,
matching how Superset actually issues chart/SQL Lab queries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 05:56:24 -07:00
rusackasandClaude Opus 4.8 cd2638e27c fix(testcontainers): address bot + reviewer feedback on db2 baseline, mariadb host, mongodb pagination test, weak type assertions, monetdb readiness, and CI path coverage
- drop `db2` from the `testcontainers[...]` extra in development.in/.txt:
  it transitively pulls `ibm-db-sa`/`ibm-db` into the baseline dev lockfile,
  which has no Linux arm64 wheel and breaks the multi-platform dev Docker
  image build; the db2 CI leg already installs it on demand separately.
- mariadb: preserve a remote Docker daemon's real host, only rewrite the
  literal "localhost" case to 127.0.0.1.
- testcontainers.yml: widen pull_request paths to superset/db_engine_specs/**,
  pyproject.toml, and the requirements manifests so a driver/lockfile-only
  change can't bypass this coverage.
- mongodb: use Core `select(...).limit().offset()` instead of a literal SQL
  string, so the pagination test actually exercises the dialect's own
  compilation.
- monetdb/mongodb/yugabytedb: assert generic_type/sqla_type on the mapped
  column spec, not just that a spec was returned.
- monetdb: wait for the exposed port (not just a log line that predates
  `monetdbd start -n`) before considering the container ready.
- timescaledb: pin the image to a specific tag instead of the mutable
  `latest-pg16`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 02:35:46 -07:00
Superset Dev 58b37d0fa6 fix(testcontainers): disable implicit autoincrement on pagination test's id column
A single-column integer primary key implicitly becomes AUTO_INCREMENT on
MySQL/MariaDB. That column type treats an explicit 0 as NULL by default,
so inserting id=0 got silently reassigned to 1, colliding with the
explicit id=1 row in the same batch insert -- reproduced locally against
a real mariadb:11 container and confirmed fixed with autoincrement=False.

Supersedes the previous (incorrect) guess that a leftover table from an
earlier run was the cause.
2026-08-27 23:13:20 -07:00
Superset Dev 782104964e fix(testcontainers): drop stale pilot_pagination table before recreating
create_all() defaults to checkfirst=True, silently skipping creation (and
leaving old rows behind) if the table already exists from a prior run,
which collided with the fresh id=0-9 insert on primary key for mariadb.
2026-08-27 23:01:27 -07:00
Superset Dev 24e1c544ad fix(testcontainers): force 127.0.0.1 for mariadb, not localhost
MySqlContainer.get_connection_url() has no host override and defaults to
get_container_host_ip(), which returns the literal string "localhost" on
native Linux Docker (e.g. GitHub Actions runners). MySQLdb (mysqlclient)
treats a "localhost" host specially and attempts a Unix socket connection
instead of TCP, which fails since there's no local MySQL socket -- the
container is only reachable over the network. Confirmed on a real GHA
run: "Can't connect to local MySQL server through socket
'/var/run/mysqld/mysqld.sock'".

This was never caught locally because of the separate, unrelated
mysqlclient linking issue on this dev machine, which crashed before ever
reaching a real connection attempt.
2026-08-27 22:50:55 -07:00
Superset Dev 1333ed12c7 feat(ci): expand testcontainers coverage to mariadb, timescaledb, yugabytedb, monetdb, mongodb
Adds five more dialects to the testcontainers suite, stacked on top of the
7-dialect pilot in feat/testcontainers-nightly-pilot: mariadb, timescaledb,
yugabytedb, monetdb, mongodb.

mariadb, timescaledb, and yugabytedb are all wire-compatible with an
existing base dialect (MySQL and Postgres respectively), so they reuse
testcontainers' MySqlContainer/PostgresContainer classes pointed at a
different image rather than needing new container-class wiring.
yugabytedb specifically cannot reuse PostgresContainer's built-in
readiness check, though: that execs `psql`, which the yugabyte image
doesn't ship (only its own `ysqlsh`) -- uses a generic DockerContainer
instead, started via `yugabyted start` and waiting on its own final
startup log line.

monetdb has no native testcontainers module; uses a generic DockerContainer
with the documented MDB_* environment variables. Publishes an amd64-only
image (confirmed running under Rosetta/QEMU emulation on Apple Silicon,
unlike CrateDB's harder x86-64-v3 CPU requirement).

mongodb needed a different data-setup approach, like elasticsearch before
it: documents get inserted via the native pymongo driver, not SQL INSERT,
since MongoDB is schemaless and Superset talks to it through pymongosql
(a SQL-to-MongoDB translation layer requiring a `?mode=superset` query
param). Two real bugs surfaced writing this one: testcontainers'
MongoDbContainer.get_connection_url() has no database path or query
string at all, so naively appending "&mode=superset" glued directly onto
the port number instead of starting a query string; and the root user
MongoDbContainer creates lives in the `admin` database, so connecting
with a different default database in the URL requires authSource=admin
or authentication fails outright. Confirmed pymongosql supports OFFSET
(maps to MongoDB's native `skip`), unlike Elasticsearch's SQL layer.

mariadb could not be verified locally in this environment: mysqlclient
(MySQLdb) has a pre-existing, unrelated native-library linking issue
against this machine's Homebrew-installed libmysqlclient. CI installs it
via apt on Linux, where this does not occur -- same accepted pattern
already used for crate/mssql/db2 in the base branch.
2026-08-27 22:44:32 -07:00
rusackasandClaude Opus 4.8 dff961a2f9 fix(deps): regenerate development.txt lockfile for ibm-db-sa
The db2 testcontainers extra pulls in ibm-db/ibm-db-sa transitively;
the pinned lockfile was missing those entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 19:53:00 -07:00
rusackasandClaude Opus 4.8 03ebf2add4 fix(testcontainers): address remaining reviewer feedback (db2 arm64, cockroachdb DBAPI docs, stale workflow refs)
- requirements/development.in + testcontainers.yml: scope the `db2` extra
  (`ibm-db-sa`/`ibm-db`) out of the baseline dev install. `ibm-db` ships no
  Linux arm64 wheel, so bundling it there broke the multi-platform dev
  Docker image build on push. The testcontainers CI job now installs it
  directly, only for its own db2 matrix leg.
- cockroachdb.py: list `psycopg2-binary` alongside `sqlalchemy-cockroachdb`
  in the `pypi_packages` metadata, since a plain `cockroachdb://` URL can't
  connect without a DBAPI and sqlalchemy-cockroachdb doesn't install one.
- test_cockroachdb.py/test_crate.py/test_trino.py: fix stale docstring
  references to the old `nightly-testcontainers.yml` filename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 19:53:00 -07:00
rusackasandClaude Opus 4.8 ce94f79a09 fix(testcontainers): address reviewer feedback on cockroachdb DBAPI, CI isolation, and test rigor
- pyproject.toml: pin psycopg2-binary alongside sqlalchemy-cockroachdb --
  the latter declares no DBAPI dependency of its own, so the documented
  `apache-superset[cockroachdb]` install couldn't actually connect.
- testcontainers.yml: scope the concurrency group by ref so a PR run and
  the nightly cron (or two different PRs) no longer cancel each other.
- test_cockroachdb.py: assert the actual generic/SQLAlchemy type, matching
  the Trino test, instead of only checking a column spec was found.
- pytest.ini + new `testcontainers` marker + _driver.py: exclude
  tests/testcontainers/ from a plain `pytest` run by default (it needs
  Docker), while the dedicated CI job now sets
  SUPERSET_TESTCONTAINERS_STRICT so a broken/missing driver import fails
  that job instead of silently skipping to a green, zero-tests-run result.
- UPDATING.md: the migration note now says to uninstall the old
  `cockroachdb` package outright, since reinstalling the extra alone can
  leave both packages registering the same dialect entry point.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 19:52:39 -07:00
rusackasandClaude Opus 4.8 80928bdb6c refactor(testcontainers): extract shared pagination-test body
The "paginated query returns correct rows in order" test body was
duplicated verbatim across cockroachdb/crate/db2/mssql/oracle/trino's
testcontainers suites. Factor the shared table-setup/assert logic into
_pagination.py, with an optional post-insert hook for CrateDB's
eventual-consistency REFRESH TABLE step; each call site keeps its own
test function and dialect-specific docstring.

Addresses bito-code-review feedback on #43502.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 19:52:17 -07:00
rusackasandClaude Opus 4.8 6ce414928e test(db_engine_specs): fix test_test_connection_failed regression from mssql extra
Adding the mssql extra to development.in for testcontainers coverage
installs pymssql in the dev/test env, so the existing
mssql+pymssql://url probe in test_test_connection_failed no longer
hits the "driver not found" path it was meant to exercise -- it now
attempts a real (failing) connection instead. Swap it for a
dialect+driver URI whose dialect will never resolve to an installed
extra.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 19:52:17 -07:00
Superset Dev f2d0b592c0 fix(cockroachdb): replace abandoned cockroachdb package with sqlalchemy-cockroachdb
The `cockroachdb` PyPI package (last released 2021) is abandoned, and its
SQLAlchemy dialect references sqlalchemy.dialects.postgresql.psycopg2's
PGCompiler_psycopg2, which SQLAlchemy 2.0 removed. Constructing a
cockroachdb:// engine has raised ImportError since the SQLAlchemy 2.0 bump
(#42803) landed, with nothing catching it: the driver isn't part of the
default dev/CI install, and the existing test_crdb.py only exercises
convert_dttm(), never a real engine.

Switches the `cockroachdb` extra to sqlalchemy-cockroachdb, the actively
maintained replacement already linked from CockroachDbEngineSpec's own
docs_url, adds it to the default dev install so this keeps getting
exercised, and adds a regression test that constructs a real engine
(verified to fail against the old package, pass against the new one).
2026-08-27 19:52:17 -07:00
Superset Dev 5a7fa2d1c6 feat(ci): expand testcontainers coverage to mssql, oracle, db2, elasticsearch
Adds four more dialects to the testcontainers suite (cockroachdb, crate,
trino from the initial pilot): mssql, oracle, db2, elasticsearch. All four
have native testcontainers-python container classes.

Restructures the workflow from one job running the whole suite to a
matrix, one job per dialect, running in parallel. A single slow container
would otherwise inflate wall-clock time for every dialect, not just its
own -- matrixing bounds total suite time by the slowest dialect instead of
the sum of all of them. Also renames the workflow file/name from
"Nightly-Testcontainers" to "Testcontainers" now that it runs on
pull_request (scoped via `paths`) in addition to the nightly cron.

Elasticsearch needed a different data-setup approach than the SQL-native
dialects: indices/documents get created via its REST API, not SQL INSERT,
matching how Superset actually encounters Elasticsearch in practice.
Confirmed empirically that Elasticsearch's SQL layer has no OFFSET support
at all (a real protocol limitation, already correctly documented via
ElasticSearchEngineSpec.supports_offset = False) and adjusted that
dialect's pagination test accordingly -- LIMIT/ORDER BY only, no OFFSET.

mssql and db2 could not be verified locally (no arm64 images for either;
this environment is Apple Silicon), same situation as crate's amd64-only
image from the initial pilot. Both are written against verified library
source (dialect names, connection URL construction) and will get their
first real execution on CI.
2026-08-27 19:51:23 -07:00
Superset Dev c0e32663a4 fix(deps): bump docker floor to 7.2.0 for testcontainers compatibility
docker==7.0.0 (the previously-unpinned floor) raises
`docker.errors.DockerException: ... Not supported URL scheme http+docker`
against the requests/urllib3 versions pinned elsewhere in this file --
confirmed on a real GHA run of #43502's nightly-testcontainers workflow,
where every test errored before any container was even pulled. 7.2.0 is
confirmed working, both locally and against a live container.
2026-08-27 19:51:23 -07:00
Superset Dev fa36e2565a temp: run nightly-testcontainers on this PR + add actions-timeline
Adds a scoped pull_request trigger so this workflow runs on this PR
itself, to get real GHA runner timing before deciding whether/how to
adopt this pattern more broadly. Also adds the actions-timeline job
(same pattern as superset-python-presto-hive.yml) to visualize per-step
duration.

Revert the pull_request trigger before merge -- it's here to measure
cost, not to become a permanent merge-blocking check.
2026-08-27 19:51:23 -07:00
rusackasandClaude Opus 4.8 eb91af8244 test(ci): assert mapped generic/sqla type in Trino column test
Address bot review feedback on #43502: the integration test only
checked that a ColumnSpec existed, not that INTEGER columns actually
mapped to the numeric generic type and an Integer sqla type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 19:51:23 -07:00
Superset Dev 051105098c feat(ci): add nightly testcontainers-based db_engine_specs tests
Pilot for real-container testing of db_engine_specs against actual
databases (CockroachDB, CrateDB, Trino), via testcontainers-python.
Existing unit tests mock the driver/dialect layer entirely, which cannot
catch real SQL-compilation or type-mapping bugs -- e.g. #42899, where
Trino emitted OFFSET before LIMIT for paginated queries.

Runs nightly (.github/workflows/nightly-testcontainers.yml), not on every
merge: container pulls and startup are slower and more flake-prone than
the existing mocked unit tests, and this measures that cost/signal
tradeoff before considering wider adoption.

Building this surfaced two real bugs, fixed/documented separately:
- The cockroachdb dialect was completely broken under SQLAlchemy 2.0 due
  to a dead upstream package -- fixed in #43501.
- testcontainers-python's TrinoContainer.get_connection_url() returns the
  container-internal port instead of the Docker-mapped host port; worked
  around locally, filed upstream.
2026-08-27 19:51:23 -07:00
189 changed files with 3986 additions and 4747 deletions
+8 -17
View File
@@ -24,6 +24,14 @@ updates:
- dependency-name: "@types/react-dom"
update-types: ["version-update:semver-major"]
- dependency-name: "react-icons"
# JSDOM v30 doesn't play well with Jest v30
# Source: https://jestjs.io/blog#known-issues
# GH thread: https://github.com/jsdom/jsdom/issues/3492
- dependency-name: "jest-environment-jsdom"
# `@swc/plugin-transform-imports` doesn't work with current Webpack-SWC hybrid setup
# See https://github.com/apache/superset/pull/37384#issuecomment-3793991389
# TODO: remove the plugin once Lodash usage has been migrated to a more readily tree-shakeable alternative
- dependency-name: "@swc/plugin-transform-imports"
# deck.gl and luma.gl share strict peer constraints across the root and
# plugin workspaces, and root overrides pin their transitive versions.
# Upgrade both families together in a manually validated change.
@@ -79,23 +87,6 @@ updates:
patterns:
- "ag-grid-react"
- "ag-grid-community"
swc:
patterns:
- "@swc/core"
- "@swc/plugin-emotion"
- "@swc/plugin-transform-imports"
jsonforms:
patterns:
- "@jsonforms/*"
visx:
patterns:
- "@visx/*"
emotion:
patterns:
- "@emotion/*"
fontsource:
patterns:
- "@fontsource/*"
open-pull-requests-limit: 30
versioning-strategy: increase
cooldown:
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -78,6 +78,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{matrix.language}}"
+11 -3
View File
@@ -44,12 +44,20 @@ jobs:
permissions:
pull-requests: write # to post the approving review via `gh pr review`
steps:
# Dependabot automatically attaches semver labels (major, minor and patch) to its generated PRs
- name: Fetch Dependabot metadata
id: metadata
# This exact SHA is on ASF Infra's action allowlist
# (apache/infrastructure-actions approved_patterns.yml) as of this
# writing. Do not bump without opening an Infra ticket to allow
# the new SHA first!
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
- name: Approve patch-level bump
if: contains(github.event.pull_request.labels.*.name, 'patch')
if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }}
run: |
gh pr review --approve "$PR_URL" \
--body "Auto-approved: patch-level bump only."
--body "Auto-approved: patch-level bump only ($DEPENDENCY_NAMES)."
-21
View File
@@ -66,27 +66,6 @@ jobs:
- name: "Set up liccheck"
run: |
# liccheck (as of 0.9.2) still does a bare `import pkg_resources`
# without declaring setuptools as a dependency, relying on it
# having historically been bundled. setuptools 81+ (installed
# above via requirements/base.txt) dropped the pkg_resources
# subpackage entirely, so liccheck's own import breaks outright.
#
# Reinstalling an older setuptools would restore pkg_resources but
# would also downgrade the *real* setuptools install, which then
# trips liccheck's own working_set.resolve() -- it cross-checks
# requirements/base.txt's declared `setuptools==84.0.0` against
# what's actually installed, and a downgrade makes those disagree.
#
# Instead, vendor just the pkg_resources/ package files from an
# old setuptools wheel into site-packages, leaving the real
# setuptools install (and its dist-info metadata) untouched. This
# gives liccheck an importable pkg_resources whose own working-set
# scan still correctly reports the real installed setuptools
# version, so no conflict is raised.
pip download "setuptools<81" --no-deps -d /tmp/old-setuptools
python -m zipfile -e /tmp/old-setuptools/setuptools-*.whl /tmp/old-setuptools-extracted/
cp -r /tmp/old-setuptools-extracted/pkg_resources "$(python -c 'import site; print(site.getsitepackages()[0])')/"
uv pip install --system liccheck
- name: "Run liccheck"
run: |
+188
View File
@@ -0,0 +1,188 @@
# db_engine_specs tests against real databases (testcontainers)
name: Testcontainers
# Spins up real Docker containers (see tests/testcontainers/ for the current
# dialect list) via testcontainers-python, which catches real dialect/driver
# regressions -- the kind mocked db_engine_specs unit tests structurally
# cannot, e.g. apache/superset#42899 (Trino emitting OFFSET before LIMIT).
# Runs on a nightly cron (catches drift from a driver's own releases, not
# just from Superset's changes) and on pull_request, scoped via `paths` to
# only PRs that actually touch this test suite or the workflow itself, so
# unrelated PRs across the repo are never affected.
#
# A matrix entry can set `nightly_only: true` to run only on the cron (or a
# manual workflow_dispatch), never on pull_request -- for a dialect whose
# image is too heavy (a multi-service cluster, a many-GB image, a slow
# licensed installer) to justify adding its wall-clock/resource cost to
# every PR that merely touches this suite. Omit the field entirely for a
# normal dialect; it isn't nightly-only by default.
permissions:
contents: read
on:
schedule:
- cron: "0 5 * * *"
workflow_dispatch: {}
pull_request:
paths:
- ".github/workflows/testcontainers.yml"
- "tests/testcontainers/**"
- "superset/db_engine_specs/**"
- "pyproject.toml"
- "requirements/development.in"
- "requirements/development.txt"
concurrency:
# Scoped by ref, not just workflow name -- otherwise every PR run and the
# nightly cron share one group, and starting the workflow on another PR
# (or the nightly firing mid-PR-run) cancels an unrelated in-progress run.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
testcontainers:
runs-on: ubuntu-26.04
strategy:
fail-fast: false
matrix:
include:
# One job per dialect rather than one job for the whole suite: a
# single slow container would otherwise inflate the wall-clock
# time for every dialect, not just its own. Running in parallel
# means the suite's total time is bounded by the slowest dialect,
# not the sum of all of them. Db2's first-boot init is documented
# upstream as notably slow (a real instance bring-up, not just a
# process start) and untested locally here (no arm64 image), so
# it gets a wider timeout margin than the rest until real CI data
# says otherwise.
- dialect: cockroachdb
timeout: 10
- dialect: crate
timeout: 10
- dialect: trino
timeout: 10
- dialect: mssql
timeout: 10
- dialect: elasticsearch
timeout: 10
- dialect: oracle
timeout: 15
- dialect: db2
timeout: 25
- dialect: mariadb
timeout: 10
- dialect: timescaledb
timeout: 10
- dialect: yugabytedb
timeout: 10
- dialect: monetdb
timeout: 10
- dialect: mongodb
timeout: 10
- dialect: postgres
timeout: 10
- dialect: mysql
timeout: 10
- dialect: clickhouse
timeout: 10
# StarRocks' allin1-ubuntu image brings up both FE and BE in one
# container, which is a heavier bring-up than a single-process
# database -- wider margin until real CI data says otherwise.
- dialect: starrocks
timeout: 15
- dialect: databend
timeout: 10
- dialect: risingwave
timeout: 10
- dialect: firebird
timeout: 10
- dialect: ydb
timeout: 10
# OceanBase bootstraps a distributed-style cluster even in
# single-node MODE=MINI -- too heavy for every PR's CI budget, so
# it runs on the nightly cron / manual dispatch only.
- dialect: oceanbase
timeout: 20
nightly_only: true
timeout-minutes: ${{ matrix.timeout }}
env:
PYTHONPATH: ${{ github.workspace }}
SUPERSET_TESTENV: true
SUPERSET_SECRET_KEY: not-a-secret
# This job's matrix installs exactly one dialect's testcontainers
# driver for exactly this job, so treat that driver as required: a
# broken/missing import should fail the job, not silently skip to a
# misleadingly green, zero-tests-run result. See _driver.py.
SUPERSET_TESTCONTAINERS_STRICT: true
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: ./.github/actions/setup-backend/
with:
python-version: current
- name: Install db2 driver (ibm-db-sa)
# ibm-db (the db2 DBAPI) ships no Linux arm64 wheel, so it's kept out
# of the baseline dev install (requirements/development.in) to avoid
# breaking the multi-platform dev Docker image build. Install it here
# instead, only for this leg of the matrix.
if: matrix.dialect == 'db2'
run: uv pip install --system -e .[db2]
- name: Install oceanbase driver (oceanbase_py)
# oceanbase_py pins sqlalchemy-utils>=0.38.3,<0.39, which conflicts
# outright with Superset's own sqlalchemy-utils==0.42.1 pin -- kept
# out of the baseline dev install for the same reason as db2 above.
# Installed as its own standalone package (not via `-e .[oceanbase]`)
# so --no-deps only skips *oceanbase_py's* dependencies -- applied
# to `-e .[oceanbase]` instead, --no-deps blocks pip from installing
# anything the extras marker pulls in, including oceanbase_py
# itself, which "succeeds" without actually installing it
# (confirmed on real CI: the install step reported success, but the
# module was still missing). This job only needs oceanbase_py's
# dialect module importable, not its sqlalchemy-utils dependency
# satisfied, since nothing here calls into it.
if: matrix.dialect == 'oceanbase'
run: uv pip install --system --no-deps "oceanbase_py>=0.0.1.2"
- name: Install Firebird client library (libfbclient2)
# sqlalchemy-firebird's driver (firebird-driver) is a pure-Python
# ctypes wrapper (its wheel is py3-none-any) that dynamically loads
# the native Firebird client library from the host at import time
# -- it doesn't bundle that library itself, so it has to come from
# the system package manager, only for this leg of the matrix.
if: matrix.dialect == 'firebird'
run: |
sudo apt-get update
sudo apt-get install -y libfbclient2
- name: Run testcontainers db_engine_specs tests (${{ matrix.dialect }})
# A job-level `if:` can't reference `matrix` (only github/inputs/
# needs/vars are available there), so the nightly_only skip has to
# live on the step instead. A dialect without `nightly_only` set
# evaluates the left side true (unset is null, and `null != true`
# is true) and always runs; one WITH it set only runs on the cron
# or a manual dispatch, never on pull_request.
if: >-
matrix.nightly_only != true ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch'
run: |
pytest --durations-min=2 -v -m testcontainers \
./tests/testcontainers/db_engine_specs/test_${{ matrix.dialect }}.py \
--junit-xml=test-results/junit-testcontainers-${{ matrix.dialect }}.xml
- name: Upload JUnit test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: junit-results-testcontainers-${{ matrix.dialect }}
path: test-results/
retention-days: 7
actions-timeline:
needs: [testcontainers]
if: always()
runs-on: ubuntu-26.04
permissions:
actions: read
steps:
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
-1
View File
@@ -10,7 +10,6 @@
.stylelintignore
.flake8
.nvmrc
.npmrc
.rat-excludes
.swcrc
.*log
+1 -1
View File
@@ -25,7 +25,7 @@ assists people when migrating to a new version.
## Next
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed should `pip uninstall cockroachdb && pip install sqlalchemy-cockroachdb` (or simply reinstall the extra) to restore CockroachDB connectivity.
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed must `pip uninstall cockroachdb` before reinstalling the extra -- both packages register the same `cockroachdb` SQLAlchemy dialect entry point, so leaving the old one in place can still load the abandoned implementation.
### MCP tool results preserve stored string values
@@ -493,8 +493,8 @@ Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in or
First, be sure you are using the following versions of Node.js and npm:
- `Node.js`: Version 24 (see `superset-frontend/.nvmrc` for the exact version)
- `npm`: Version 11
- `Node.js`: Version 22 (LTS)
- `npm`: Version 10
We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage your node environment:
@@ -507,8 +507,8 @@ export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
cd superset-frontend
nvm install
nvm use
nvm install --lts
nvm use --lts
```
Or if you use the default macOS starting with Catalina shell `zsh`, try:
+21 -7
View File
@@ -16,7 +16,7 @@
# under the License.
[build-system]
requires = ["setuptools>=84.0.0", "wheel"]
requires = ["setuptools>=40.9.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
@@ -148,10 +148,9 @@ clickhouse = ["clickhouse-connect>=1.7.2, <2.0"]
# 2.0). sqlalchemy-cockroachdb is the actively maintained replacement,
# already linked from CockroachDbEngineSpec.metadata's docs_url, and
# registers the same `cockroachdb` SQLAlchemy dialect entry point.
# sqlalchemy-cockroachdb depends only on SQLAlchemy itself, not on a DBAPI
# driver, so psycopg2-binary is pinned alongside it (matching the `postgres`
# extra) to keep this extra self-contained -- CockroachDB speaks the
# PostgreSQL wire protocol, so psycopg2 is what actually opens connections.
# sqlalchemy-cockroachdb itself declares no DBAPI dependency (its own docs
# require picking one), so pull in the same psycopg2-binary pin as the
# `postgres` extra -- CockroachDB speaks the Postgres wire protocol.
cockroachdb = ["sqlalchemy-cockroachdb>=2.0.0, <3", "psycopg2-binary==2.9.12"]
crate = ["sqlalchemy-cratedb>=0.43.1, <1"]
# sqlalchemy-d1's only release (0.1.0, Nov 2025) pins sqlalchemy<2,>=1.4,
@@ -196,7 +195,7 @@ excel = ["xlrd>=2.0.2, <2.1"]
excel-export = ["boto3"]
fastmcp = [
"fastmcp>=3.4.7,<4.0",
"mcp>=1.29.1,<3.0",
"mcp>=1.29.1,<2.0",
# tiktoken backs the response-size-guard token estimator. Without
# it, the middleware falls back to a coarser character-based
# heuristic that under-counts JSON-heavy MCP responses.
@@ -223,6 +222,11 @@ impala = ["impyla>=0.24.0, <0.25"]
# superset/db_engine_specs/kusto.py's known_incompatibilities metadata.
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
kylin = ["kylinpy>=2.8.4, <2.9"]
# MariaDB is a MySQL fork implementing the same wire protocol - connects via
# the plain mysql dialect, same driver as mysql.
mariadb = ["apache-superset[mysql]"]
monetdb = ["sqlalchemy-monetdb>=2.1.0, <3", "pymonetdb>=1.9.1, <2"]
mongodb = ["pymongosql>=0.7.3, <1"]
mssql = ["pymssql>=2.3.13, <3"]
# motherduck is an alias for duckdb - MotherDuck works via the duckdb driver
motherduck = ["apache-superset[duckdb]"]
@@ -271,6 +275,9 @@ tdengine = [
"taos-ws-py>=0.7.0"
]
teradata = ["teradatasql>=20.0.0.66"]
# TimescaleDB is a genuine Postgres extension, not a fork - connects via the
# plain postgresql dialect, same driver as postgres.
timescaledb = ["apache-superset[postgres]"]
thumbnails = [] # deprecated, will be removed in 7.0
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
@@ -278,11 +285,18 @@ starrocks = ["starrocks>=1.3.4, <2"]
doris = ["pydoris>=1.2.0, <2.0.0"]
oceanbase = ["oceanbase_py>=0.0.1.2"]
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.8"]
# YugabyteDB's YSQL layer is fully Postgres-wire compatible - connects via
# the plain postgresql dialect, same driver as postgres.
yugabytedb = ["apache-superset[postgres]"]
development = [
# no bounds for apache-superset-extensions-cli until a stable version
"apache-superset-extensions-cli",
"boto3",
"docker",
# 7.0.0 raises `docker.errors.DockerException: ... Not supported URL
# scheme http+docker` against the requests/urllib3 versions pinned
# elsewhere in this file -- breaks testcontainers (tests/testcontainers/)
# before any container even starts. 7.2.0 is confirmed working.
"docker>=7.2.0",
"flask-testing",
"freezegun",
"grpcio>=1.82.1",
+7 -1
View File
@@ -19,7 +19,13 @@ testpaths =
tests
python_files = *_test.py test_*.py *_tests.py *viz/utils.py
# `-p no:warnings` temporarily disabled in favor of more finely tuned `filterwarnings`.
#addopts = -p no:warnings
# `not testcontainers` excludes tests/testcontainers/ by default: those spin up
# real Docker containers, and `testpaths = tests` would otherwise pull them into
# every plain `pytest` run. The dedicated CI job (testcontainers.yml) overrides
# this with an explicit `-m testcontainers` to run them.
addopts = -m "not testcontainers"
markers =
testcontainers: exercises a real database via testcontainers-python (needs Docker); excluded by default, see .github/workflows/testcontainers.yml
asyncio_mode = auto
# `ignore` is effectively equivalent to `-p no:warnings`.
+2 -2
View File
@@ -52,11 +52,11 @@ marshmallow-sqlalchemy>=1.5.0
# needed for python 3.12 support
openapi-schema-validator>=0.6.3
# Pin setuptools <85 until all dependencies migrate from pkg_resources to importlib.metadata
# Pin setuptools <81 until all dependencies migrate from pkg_resources to importlib.metadata
# pkg_resources is deprecated and will be removed in setuptools 81+ (around 2025-11-30)
# Known affected packages: Preset's 'clients' package
# See docs/docs/contributing/pkg-resources-migration.md for details
setuptools<85
setuptools<81
# google-auth 2.53+ dropped its transitive dependency on cachetools, which is
# imported directly by superset.db_engine_specs.aws_iam. We declare cachetools
+1 -1
View File
@@ -366,7 +366,7 @@ rpds-py==0.25.0
# via
# jsonschema
# referencing
setuptools==84.0.0
setuptools==80.9.0
# via -r requirements/base.in
shillelagh==1.4.5
# via apache-superset (pyproject.toml)
+28 -1
View File
@@ -16,5 +16,32 @@
# specific language governing permissions and limitations
# under the License.
#
-e .[development,bigquery,cockroachdb,druid,duckdb,fastmcp,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]
-e .[development,bigquery,clickhouse,cockroachdb,crate,databend,druid,duckdb,elasticsearch,fastmcp,firebird,gevent,gsheets,monetdb,mongodb,mssql,mysql,oracle,postgres,presto,prophet,risingwave,starrocks,trino,thumbnails,ydb]
-e ./superset-extensions-cli[test]
# testcontainers-backed db_engine_specs tests (tests/testcontainers/) --
# see .github/workflows/testcontainers.yml
#
# `db2` (the `ibm-db-sa`/`ibm-db` driver) and `oceanbase` (the `oceanbase_py`
# driver) are both deliberately left out of the baseline dev install above:
# `ibm-db` ships no Linux arm64 wheel, breaking the multi-platform
# (amd64+arm64) dev Docker image build; `oceanbase_py` pins
# `sqlalchemy-utils>=0.38.3,<0.39`, which conflicts outright with Superset's
# own `sqlalchemy-utils==0.42.1` pin -- there's no version of both that can
# coexist in one resolved environment. Both testcontainers CI jobs install
# their driver on demand instead, only for their own matrix leg -- see
# .github/workflows/testcontainers.yml.
#
# mariadb/timescaledb/yugabytedb need no testcontainers extra of their own:
# they reuse the postgres/mysql container classes pointed at a different
# image, and psycopg2-binary/mysqlclient are already pulled in above via
# the postgres/mysql extras. Plain postgres/mysql obviously need nothing
# extra either. clickhouse and starrocks also need no testcontainers extra:
# ClickHouseContainer has no driver import of its own (clickhouse-connect,
# pulled in above via the clickhouse extra, is all the test needs), and
# StarRocks has no dedicated testcontainers module at all -- its test uses
# a generic DockerContainer plus the same mysqlclient the mysql extra
# already provides. databend/risingwave/firebird/ydb are the same story:
# none has a dedicated testcontainers module, so each test uses a generic
# DockerContainer plus whatever driver its own extra above already
# provides.
testcontainers[cockroachdb,cratedb,mongodb,mssql,mysql,oracle,postgres,trino]>=4.15.0,<5
+155 -8
View File
@@ -12,10 +12,17 @@
# apache-superset
aiofile==3.9.0
# via py-key-value-aio
aiohappyeyeballs==2.7.1
# via aiohttp
aiohttp==3.14.3
# via ydb
aiosignal==1.4.0
# via aiohttp
alembic==1.15.2
# via
# -c requirements/base-constraint.txt
# flask-migrate
# starrocks
amqp==5.3.1
# via
# -c requirements/base-constraint.txt
@@ -24,6 +31,8 @@ annotated-types==0.7.0
# via
# -c requirements/base-constraint.txt
# pydantic
antlr4-python3-runtime==4.13.2
# via pymongosql
anyio==4.11.0
# via
# httpx
@@ -42,9 +51,12 @@ apsw==3.50.1.0
# shillelagh
astroid==3.3.10
# via pylint
asyncmy2==0.2.21
# via starrocks
attrs==25.3.0
# via
# -c requirements/base-constraint.txt
# aiohttp
# cattrs
# cyclopts
# jsonschema
@@ -65,6 +77,7 @@ backports-tarfile==1.2.0
backports-zstd==1.6.0
# via
# -c requirements/base-constraint.txt
# clickhouse-connect
# flask-compress
bcrypt==4.3.0
# via
@@ -117,8 +130,11 @@ celery==5.6.3
certifi==2026.5.20
# via
# -c requirements/base-constraint.txt
# clickhouse-connect
# elasticsearch
# httpcore
# httpx
# opensearch-py
# requests
cffi==2.0.0
# via
@@ -160,6 +176,8 @@ click-repl==0.3.0
# via
# -c requirements/base-constraint.txt
# celery
clickhouse-connect==1.7.2
# via apache-superset
cmdstanpy==1.1.0
# via prophet
colorama==0.4.6
@@ -171,6 +189,8 @@ contourpy==1.0.7
# via matplotlib
coverage==7.6.8
# via pytest-cov
crate==2.2.1
# via sqlalchemy-cratedb
cron-descriptor==1.4.5
# via
# -c requirements/base-constraint.txt
@@ -186,14 +206,20 @@ cryptography==50.0.0
# authlib
# google-auth
# joserfc
# oracledb
# paramiko
# pyjwt
# pymysql
# pyopenssl
# secretstorage
cycler==0.12.1
# via matplotlib
cyclopts==4.2.4
# via fastmcp-slim
databend-driver==0.34.2
# via databend-sqlalchemy
databend-sqlalchemy==0.5.5
# via apache-superset
db-dtypes==1.3.1
# via pandas-gbq
defusedxml==0.7.1
@@ -216,8 +242,11 @@ dnspython==2.7.0
# via
# -c requirements/base-constraint.txt
# email-validator
docker==7.0.0
# via apache-superset
# pymongo
docker==7.2.0
# via
# apache-superset
# testcontainers
docstring-parser==0.17.0
# via cyclopts
docutils==0.22.2
@@ -228,6 +257,10 @@ duckdb==1.5.5
# duckdb-engine
duckdb-engine==0.17.0
# via apache-superset
elasticsearch==7.17.13
# via elasticsearch-dbapi
elasticsearch-dbapi==0.2.13
# via apache-superset
email-validator==2.2.0
# via
# -c requirements/base-constraint.txt
@@ -237,6 +270,8 @@ et-xmlfile==2.0.0
# via
# -c requirements/base-constraint.txt
# openpyxl
events==0.5
# via opensearch-py
exceptiongroup==1.3.0
# via fastmcp-slim
fastmcp==3.4.7
@@ -247,6 +282,10 @@ filelock==3.20.3
# via
# -c requirements/base-constraint.txt
# virtualenv
firebird-base==2.0.3
# via firebird-driver
firebird-driver==2.0.3
# via sqlalchemy-firebird
flask==2.3.3
# via
# -c requirements/base-constraint.txt
@@ -327,12 +366,18 @@ fonttools==4.60.2
# via matplotlib
freezegun==1.5.1
# via apache-superset
frozenlist==1.8.0
# via
# aiohttp
# aiosignal
future==1.0.0
# via pyhive
geographiclib==2.0
# via
# -c requirements/base-constraint.txt
# geopy
geojson==3.3.0
# via sqlalchemy-cratedb
geopy==2.4.1
# via
# -c requirements/base-constraint.txt
@@ -389,6 +434,7 @@ grpcio==1.83.0
# apache-superset
# google-api-core
# grpcio-status
# ydb
grpcio-status==1.60.1
# via google-api-core
gunicorn==26.2.0
@@ -414,6 +460,7 @@ httpx==0.28.1
# via
# fastmcp-slim
# mcp
# testcontainers
httpx-sse==0.4.1
# via mcp
humanize==4.12.3
@@ -430,6 +477,7 @@ idna==3.15
# httpx
# requests
# url-normalize
# yarl
importlib-metadata==8.7.0
# via
# keyring
@@ -468,6 +516,7 @@ jmespath==1.1.0
# via
# boto3
# botocore
# pymongosql
joserfc==1.7.2
# via fastmcp-slim
jsonpath-ng==1.8.0
@@ -500,6 +549,8 @@ kombu==5.6.2
# via
# -c requirements/base-constraint.txt
# celery
lark==1.3.1
# via starrocks
lazy-object-proxy==1.10.0
# via openapi-spec-validator
limits==5.1.0
@@ -507,7 +558,9 @@ limits==5.1.0
# -c requirements/base-constraint.txt
# flask-limiter
lz4==4.4.5
# via trino
# via
# clickhouse-connect
# trino
mako==1.4.1
# via
# -c requirements/base-constraint.txt
@@ -567,6 +620,10 @@ msgspec==0.19.0
# via
# -c requirements/base-constraint.txt
# flask-session
multidict==6.7.1
# via
# aiohttp
# yarl
mysqlclient==2.2.8
# via apache-superset
nh3==0.3.6
@@ -605,14 +662,22 @@ openpyxl==3.1.5
# via
# -c requirements/base-constraint.txt
# pandas
opensearch-py==2.8.0
# via elasticsearch-dbapi
opentelemetry-api==1.39.1
# via fastmcp-slim
oracledb==4.0.2
# via
# apache-superset
# testcontainers
ordered-set==4.1.0
# via
# -c requirements/base-constraint.txt
# flask-limiter
orjson==3.11.9
# via trino
# via
# crate
# trino
packaging==25.0
# via
# -c requirements/base-constraint.txt
@@ -620,8 +685,8 @@ packaging==25.0
# apispec
# db-dtypes
# deprecation
# docker
# duckdb-engine
# elasticsearch-dbapi
# fastmcp-slim
# google-cloud-bigquery
# kombu
@@ -631,6 +696,8 @@ packaging==25.0
# pytest
# shillelagh
# sqlalchemy-bigquery
# sqlalchemy-firebird
# ydb
pandas==2.3.3
# via
# -c requirements/base-constraint.txt
@@ -663,7 +730,7 @@ pillow==12.3.0
# -c requirements/base-constraint.txt
# apache-superset
# matplotlib
pip==26.2.1
pip==25.1.1
# via apache-superset
platformdirs==4.3.8
# via
@@ -692,16 +759,22 @@ prompt-toolkit==3.0.51
# via
# -c requirements/base-constraint.txt
# click-repl
propcache==0.5.2
# via
# aiohttp
# yarl
prophet==1.4.0
# via apache-superset
proto-plus==1.25.0
# via google-api-core
protobuf==5.29.6
# via
# firebird-base
# google-api-core
# googleapis-common-protos
# grpcio-status
# proto-plus
# ydb
psutil==6.1.0
# via
# apache-superset
@@ -775,6 +848,24 @@ pyjwt==2.13.0
# mcp
pylint==3.3.7
# via apache-superset
pymonetdb==1.9.1
# via
# apache-superset
# sqlalchemy-monetdb
pymongo==4.17.0
# via
# pymongosql
# testcontainers
pymongosql==0.7.3
# via apache-superset
pymssql==2.3.13
# via
# apache-superset
# testcontainers
pymysql==1.2.0
# via
# starrocks
# testcontainers
pynacl==1.6.2
# via
# -c requirements/base-constraint.txt
@@ -819,11 +910,13 @@ python-dateutil==2.9.0.post0
# botocore
# celery
# croniter
# firebird-driver
# flask-appbuilder
# freezegun
# google-cloud-bigquery
# holidays
# matplotlib
# opensearch-py
# pandas
# pyhive
# shillelagh
@@ -834,6 +927,7 @@ python-dotenv==1.2.2
# apache-superset
# fastmcp-slim
# pydantic-settings
# testcontainers
python-ldap==3.4.7
# via apache-superset
python-multipart==0.0.29
@@ -877,6 +971,7 @@ requests==2.33.0
# google-api-core
# google-cloud-bigquery
# jsonschema-path
# opensearch-py
# pydruid
# pyhive
# requests-cache
@@ -920,7 +1015,7 @@ secretstorage==3.5.0
# via keyring
semver==3.0.4
# via apache-superset-extensions-cli
setuptools==84.0.0
setuptools==80.9.0
# via
# -c requirements/base-constraint.txt
# nodeenv
@@ -955,7 +1050,9 @@ sqlalchemy==2.0.52
# alembic
# apache-superset
# apache-superset-core
# databend-sqlalchemy
# duckdb-engine
# elasticsearch-dbapi
# flask-appbuilder
# flask-sqlalchemy
# marshmallow-sqlalchemy
@@ -963,7 +1060,14 @@ sqlalchemy==2.0.52
# sqlalchemy-bigquery
# sqlalchemy-cockroachdb
# sqlalchemy-continuum
# sqlalchemy-cratedb
# sqlalchemy-firebird
# sqlalchemy-monetdb
# sqlalchemy-risingwave
# sqlalchemy-utils
# starrocks
# testcontainers
# ydb-sqlalchemy
sqlalchemy-bigquery==1.17.2
# via apache-superset
sqlalchemy-cockroachdb==2.0.4
@@ -972,6 +1076,16 @@ sqlalchemy-continuum==1.7.0
# via
# -c requirements/base-constraint.txt
# apache-superset
sqlalchemy-cratedb==0.43.1
# via
# apache-superset
# testcontainers
sqlalchemy-firebird==2.2.0
# via apache-superset
sqlalchemy-monetdb==2.1.0
# via apache-superset
sqlalchemy-risingwave==2.1.0
# via apache-superset
sqlalchemy-utils==0.42.1
# via
# -c requirements/base-constraint.txt
@@ -983,6 +1097,7 @@ sqlglot==30.17.0
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
# ydb-sqlglot-plugin
sqloxide==0.1.51
# via apache-superset
sse-starlette==3.0.2
@@ -995,6 +1110,8 @@ starlette==1.3.1
# via
# fastmcp-slim
# mcp
starrocks==1.3.4
# via apache-superset
statsd==4.0.1
# via apache-superset
syntaqlite==0.9.0
@@ -1003,6 +1120,8 @@ tabulate==0.10.0
# via
# -c requirements/base-constraint.txt
# apache-superset
testcontainers==4.15.0
# via -r requirements/development.in
tiktoken==0.14.0
# via apache-superset
tomli-w==1.2.0
@@ -1014,10 +1133,14 @@ tqdm==4.67.1
# cmdstanpy
# prophet
trino==0.339.0
# via apache-superset
# via
# apache-superset
# testcontainers
typing-extensions==4.16.0
# via
# -c requirements/base-constraint.txt
# aiohttp
# aiosignal
# alembic
# anyio
# apache-superset
@@ -1029,6 +1152,7 @@ typing-extensions==4.16.0
# limits
# mcp
# opentelemetry-api
# oracledb
# py-key-value-aio
# pydantic
# pydantic-core
@@ -1037,6 +1161,7 @@ typing-extensions==4.16.0
# shillelagh
# sqlalchemy
# starlette
# testcontainers
# typing-inspection
typing-inspection==0.4.2
# via
@@ -1064,13 +1189,22 @@ urllib3==2.7.0
# via
# -c requirements/base-constraint.txt
# botocore
# clickhouse-connect
# crate
# docker
# elasticsearch
# opensearch-py
# requests
# requests-cache
# testcontainers
uvicorn==0.37.0
# via
# fastmcp-slim
# mcp
verlib2==0.3.2
# via
# crate
# sqlalchemy-cratedb
vine==5.1.0
# via
# -c requirements/base-constraint.txt
@@ -1104,6 +1238,7 @@ wrapt==1.17.2
# via
# -c requirements/base-constraint.txt
# deprecated
# testcontainers
wtforms==3.2.2
# via
# -c requirements/base-constraint.txt
@@ -1124,6 +1259,18 @@ xlsxwriter==3.2.9
# -c requirements/base-constraint.txt
# apache-superset
# pandas
yarl==1.24.5
# via aiohttp
ydb==3.31.4
# via
# ydb-dbapi
# ydb-sqlalchemy
ydb-dbapi==0.1.23
# via ydb-sqlalchemy
ydb-sqlalchemy==0.1.22
# via apache-superset
ydb-sqlglot-plugin==0.2.8
# via apache-superset
zipp==3.23.0
# via importlib-metadata
zope-event==5.0
@@ -390,3 +390,19 @@ def get_session() -> scoped_session:
:returns: The SQLAlchemy scoped session instance.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"Dataset",
"Database",
"Chart",
"Dashboard",
"User",
"Role",
"Group",
"Tag",
"KeyValue",
"Subject",
"CoreModel",
"get_session",
]
@@ -183,3 +183,10 @@ def prompt(
"MCP prompt decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = [
"tool",
"prompt",
"ToolAnnotations",
]
@@ -55,3 +55,9 @@ class SavedQueryDAO(BaseDAO[SavedQuery]):
model_cls = None
base_filter = None
id_column_name = "id"
__all__ = [
"QueryDAO",
"SavedQueryDAO",
]
@@ -71,3 +71,9 @@ class SavedQuery(CoreModel):
database_id: int | None
description: str | None
user_id: int | None
__all__ = [
"Query",
"SavedQuery",
]
@@ -46,3 +46,6 @@ def get_sqlglot_dialect(database: "Database") -> Dialects:
:returns: The SQLGlot dialect enum corresponding to the database.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = ["get_sqlglot_dialect"]
@@ -165,3 +165,13 @@ class AsyncQueryHandle:
:returns: True if cancellation was successful
"""
raise NotImplementedError("Method will be replaced during initialization")
__all__ = [
"QueryStatus",
"QueryOptions",
"QueryResult",
"StatementResult",
"AsyncQueryHandle",
"CacheOptions",
]
@@ -27,3 +27,6 @@ class RestApi(BaseApi):
"""
allow_browser_login = True
__all__ = ["RestApi"]
@@ -98,3 +98,6 @@ def api(
"API decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["api"]
@@ -164,3 +164,6 @@ class AbstractSemanticViewDAO(BaseDAO[SemanticViewModel]):
:return: SemanticViewModel instance or None
"""
...
__all__ = ["AbstractSemanticLayerDAO", "AbstractSemanticViewDAO"]
@@ -97,3 +97,6 @@ def semantic_layer(
"Semantic layer decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["semantic_layer"]
@@ -21,7 +21,6 @@ from abc import ABC, abstractmethod
from typing import Any, Generic, TypeVar
from pydantic import BaseModel
from superset_core.semantic_layers.view import SemanticView
ConfigT = TypeVar("ConfigT", bound=BaseModel)
@@ -80,3 +80,6 @@ class SemanticViewModel(CoreModel):
semantic_layer_uuid: UUID
created_on: datetime | None
changed_on: datetime | None
__all__ = ["SemanticLayerModel", "SemanticViewModel"]
@@ -71,3 +71,6 @@ class TaskDAO(BaseDAO[Task]):
:returns: Task instance or None if not found or not active
"""
...
__all__ = ["TaskDAO"]
@@ -144,3 +144,9 @@ def get_context() -> TaskContext:
)
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"task",
"get_context",
]
@@ -161,3 +161,9 @@ class TaskSubscriber(CoreModel):
changed_on: datetime | None
created_by_fk: int | None
changed_by_fk: int | None
__all__ = [
"Task",
"TaskSubscriber",
]
@@ -226,3 +226,12 @@ class TaskContext(ABC):
cleanup_partial_work()
"""
...
__all__ = [
"TaskStatus",
"TaskScope",
"TaskProperties",
"TaskContext",
"TaskOptions",
]
-1
View File
@@ -1 +0,0 @@
../superset-frontend/.npmrc
-1
View File
@@ -1 +0,0 @@
min-release-age=3
+1 -1
View File
@@ -77,7 +77,7 @@ module.exports = {
// @ant-design/colors and @ant-design/fast-color are allowed through because
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
// from its CJS output, so babel-jest must transform those files.
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge|content-disposition)',
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
],
preset: 'ts-jest',
transform: {
+17 -17
View File
@@ -84,7 +84,7 @@
"antd": "^6.6.1",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^3.0.0",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dom-to-image-more": "^3.10.2",
@@ -108,7 +108,7 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.29.0",
"mapbox-gl": "^3.28.1",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -234,7 +234,7 @@
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.10",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -17451,12 +17451,12 @@
}
},
"node_modules/content-disposition": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz",
"integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=22"
"node": ">=18"
},
"funding": {
"type": "opencollective",
@@ -20208,9 +20208,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.2.tgz",
"integrity": "sha512-HgYol2zhH3KbnW9Q4FY/FcIINbsYVL6rwQESChqBC2s9SLWtw1wg05+J7SCrr3BfQpNY2ReYhf8xjpd2JhKJOQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.1.tgz",
"integrity": "sha512-oOhQTYhor88Xp8RVytq25tvBfiAjU0r9SCDC51Qop+3Wg5BR1xGMAkM+/dV4MZbcMhdaU1L9bkv6LC95JmiTig==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -28488,9 +28488,9 @@
}
},
"node_modules/mapbox-gl": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.29.0.tgz",
"integrity": "sha512-Fnh1WLsZMfihwRZY5scp456iQuZo9G97tTpb26bf/Ejsi/L7O+4dE9+I03VoOor2ul3DEOp6F2P3273omyVsNw==",
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
"integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
"license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [
"src/style-spec",
@@ -30733,9 +30733,9 @@
}
},
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
"dev": true,
"license": "MIT"
},
@@ -43490,7 +43490,7 @@
"license": "Apache-2.0",
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.29.0",
"mapbox-gl": "^3.28.1",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
+5 -5
View File
@@ -161,7 +161,7 @@
"antd": "^6.6.1",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^3.0.0",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dom-to-image-more": "^3.10.2",
@@ -185,7 +185,7 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.29.0",
"mapbox-gl": "^3.28.1",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -311,7 +311,7 @@
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.10",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -392,7 +392,7 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"core-js": "^3.38.1",
"dompurify": "^3.4.13",
"dompurify": "^3.4.11",
"esbuild": "^0.28.1",
"eslint-plugin-import": {
"eslint": "$eslint"
@@ -416,7 +416,7 @@
"brace-expansion": ">=5.0.8"
},
"nanoid@>=3 <4": "3.3.18",
"nwsapi": "^2.2.24",
"nwsapi": "^2.2.13",
"puppeteer": "^22.4.1",
"tar": "^7.5.16",
"typescript-json-schema": "^0.68.0",
@@ -65,7 +65,6 @@ export type AntdExposedProps = Pick<
| 'onOpenChange'
| 'optionRender'
| 'placeholder'
| 'prefix'
| 'showArrow'
| 'showSearch'
| 'tokenSeparators'
@@ -38,21 +38,11 @@ function formatMemory(
: ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'RB', 'QB'];
const base = binary ? 1024 : 1000;
let i = Math.max(
0,
Math.min(
suffixes.length - 1,
Math.floor(Math.log(absValue) / Math.log(base)),
),
const i = Math.min(
suffixes.length - 1,
Math.floor(Math.log(absValue) / Math.log(base)),
);
let scaled = parseFloat((absValue / Math.pow(base, i)).toFixed(decimals));
if (scaled >= base && i < suffixes.length - 1) {
i += 1;
scaled = parseFloat((absValue / Math.pow(base, i)).toFixed(decimals));
}
formatted = `${sign}${scaled}${suffixes[i]}`;
formatted = `${sign}${parseFloat((absValue / Math.pow(base, i)).toFixed(decimals))}${suffixes[i]}`;
}
if (transfer) {
@@ -119,20 +119,6 @@ export function retrieveErrorMessage(
return statusError || parseStringResponse(str);
}
function getFirstValidationError(message: JsonObject): string | undefined {
const [firstError] = Object.values(message);
if (typeof firstError === 'string') {
return firstError;
}
if (Array.isArray(firstError)) {
return firstError.find((item): item is string => typeof item === 'string');
}
return undefined;
}
export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
let error = { ...responseJson };
// Backwards compatibility for old error renderers with the new error object
@@ -140,12 +126,13 @@ export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
error.error = error.description = error.errors[0].message;
error.link = error.errors[0]?.extra?.link;
}
// Marshmallow field validation returns arrays for string messages, but
// serializes lazy translation messages as strings instead.
// Marshmallow field validation returns the error message in the format
// of { message: { field1: [msg1, msg2], field2: [msg], } }
if (!error.error && error.message) {
if (typeof error.message === 'object') {
error.error =
getFirstValidationError(error.message) || t('Invalid input');
Object.values(error.message as Record<string, string[]>)[0]?.[0] ||
t('Invalid input');
}
if (typeof error.message === 'string') {
if (checkForHtml(error.message)) {
@@ -30,7 +30,6 @@ import type {
QueryFormData,
} from '../query';
import type { JsonResponse } from '../connection';
import type { MenuItem } from '../components/Menu';
/**
* A function which returns text (or marked-up text)
@@ -165,13 +164,6 @@ export interface SliceHeaderExtension {
dashboardId: number;
}
/**
* Interface for extensions to the Slice Header more-options menu
*/
export interface SliceHeaderMenuExtension extends SliceHeaderExtension {
sliceName: string;
}
/**
* Interface for extensions to Embed Modal
*/
@@ -270,9 +262,6 @@ export type Extensions = Partial<{
'sqleditor.extension.form': ComponentType<SQLFormExtensionProps>;
'sqleditor.extension.resultTable': ComponentType<SQLResultTableExtensionProps>;
'dashboard.slice.header': ComponentType<SliceHeaderExtension>;
'dashboard.slice.header.menu': (
context: SliceHeaderMenuExtension,
) => MenuItem[];
'sqleditor.extension.customAutocomplete': (
args: CustomAutoCompleteArgs,
) => CustomAutocomplete[] | undefined;
@@ -60,31 +60,6 @@ test('formats float bytes in human readable format with default options', () =>
expect(formatter(1200.666)).toBe('1.2kB');
});
test('formats values below one byte without dropping the unit', () => {
const formatter = createMemoryFormatter();
expect(formatter(0.5)).toBe('0.5B');
expect(formatter(0.004)).toBe('0B');
expect(formatter(-0.25)).toBe('-0.25B');
const binaryFormatter = createMemoryFormatter({ binary: true });
expect(binaryFormatter(0.5)).toBe('0.5B');
});
test('rolls over to the next unit when rounding reaches the base', () => {
const formatter = createMemoryFormatter();
expect(formatter(999999)).toBe('1MB');
expect(formatter(999995)).toBe('1MB');
expect(formatter(999994)).toBe('999.99kB');
expect(formatter(-999999)).toBe('-1MB');
const binaryFormatter = createMemoryFormatter({ binary: true });
expect(binaryFormatter(1024 * 1024 - 1)).toBe('1MiB');
// the largest unit has nothing to roll over into
const largest = createMemoryFormatter();
expect(largest(Math.pow(1000, 11))).toBe('1000QB');
});
test('formats bytes in human readable format with additional binary option', () => {
const formatter = createMemoryFormatter({ binary: true });
expect(formatter(0)).toBe('0B');
@@ -244,24 +244,6 @@ test('parseErrorJson with message', () => {
});
});
test('parseErrorJson preserves string-valued validation messages', () => {
const calculatedColumnError =
'Custom SQL fields cannot be parsed as a single SQL statement.';
expect(
parseErrorJson({
message: {
'columns.0.expression': calculatedColumnError,
},
}),
).toEqual({
message: {
'columns.0.expression': calculatedColumnError,
},
error: calculatedColumnError,
});
});
test('parseErrorJson with HTML message', () => {
expect(
parseErrorJson({
@@ -38,8 +38,6 @@ import { EchartsTimeseriesSeriesType } from '../Timeseries/types';
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
truncateXAxis,
xAxisBounds,
@@ -393,8 +391,6 @@ const config: ControlPanelConfig = {
...createCustomizeSection(t('Query B'), 'B'),
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
['x_axis_time_format'],
@@ -184,8 +184,6 @@ export default function transformProps(
opacityB,
minorSplitLine,
minorTicks,
gridlines,
axisTicks,
seriesType,
seriesTypeB,
showLegend,
@@ -790,8 +788,6 @@ export default function transformProps(
}),
},
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
...(gridlines ? {} : { splitLine: { show: false } }),
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
? (TIMEGRAIN_TO_TIMESTAMP[
@@ -822,8 +818,6 @@ export default function transformProps(
min: yAxisMin,
max: yAxisMax,
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
splitLine: { show: gridlines },
minorSplitLine: { show: minorSplitLine },
axisLabel: {
formatter: getYAxisFormatter(
@@ -846,7 +840,6 @@ export default function transformProps(
min: minSecondary,
max: maxSecondary,
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
splitLine: { show: false },
minorSplitLine: { show: minorSplitLine },
axisLabel: {
@@ -48,8 +48,6 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
// shared properties
minorSplitLine: boolean;
minorTicks: boolean;
gridlines: boolean;
axisTicks: boolean;
logAxis: boolean;
logAxisSecondary: boolean;
yAxisFormat?: string;
@@ -115,8 +113,6 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
...DEFAULT_LEGEND_FORM_DATA,
annotationLayers: [],
minorSplitLine: TIMESERIES_DEFAULTS.minorSplitLine,
gridlines: TIMESERIES_DEFAULTS.gridlines,
axisTicks: TIMESERIES_DEFAULTS.axisTicks,
truncateYAxis: TIMESERIES_DEFAULTS.truncateYAxis,
truncateYAxisSecondary: TIMESERIES_DEFAULTS.truncateYAxis,
logAxis: TIMESERIES_DEFAULTS.logAxis,
@@ -44,8 +44,6 @@ import {
truncateXAxis,
xAxisBounds,
minorTicks,
axisTicks,
gridlines,
forceMaxInterval,
} from '../../controls';
import { AreaChartStackControlOptions } from '../../constants';
@@ -176,8 +174,6 @@ const config: ControlPanelConfig = {
},
],
[minorTicks],
[axisTicks],
[gridlines],
['zoomable'],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
@@ -133,8 +133,6 @@ const defaultFormData: EchartsTimeseriesFormData & {
metrics: [],
minorSplitLine: false,
minorTicks: false,
gridlines: true,
axisTicks: true,
opacity: 1,
orderDesc: false,
rowLimit: 0,
@@ -40,8 +40,6 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSectionWithoutStream,
@@ -390,8 +388,6 @@ const config: ControlPanelConfig = {
},
],
[minorTicks],
[axisTicks],
[gridlines],
['zoomable'],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
@@ -37,8 +37,6 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSection,
@@ -158,8 +156,6 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
@@ -42,8 +42,6 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSection,
@@ -482,8 +480,6 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
...createAxisControl('x'),
@@ -37,8 +37,6 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSectionWithoutStack,
@@ -107,8 +105,6 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
@@ -35,8 +35,6 @@ import { DEFAULT_FORM_DATA, TIME_SERIES_DESCRIPTION_TEXT } from '../constants';
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSection,
@@ -159,8 +157,6 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
@@ -67,8 +67,6 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
maxMarkerSize: 30,
minMarkerSize: 5,
minorSplitLine: false,
gridlines: true,
axisTicks: true,
opacity: 0.2,
orderDesc: true,
rowLimit: 10000,
@@ -283,8 +283,6 @@ export default function transformProps(
metrics,
minorSplitLine,
minorTicks,
gridlines,
axisTicks,
onlyTotal,
opacity,
orientation,
@@ -1282,8 +1280,6 @@ export default function transformProps(
}),
},
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
...(gridlines ? {} : { splitLine: { show: false } }),
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
? (TIMEGRAIN_TO_TIMESTAMP[
@@ -1328,7 +1324,7 @@ export default function transformProps(
max: yAxisMax,
minorTick: { show: isSmallChart ? false : minorTicks },
minorSplitLine: { show: isSmallChart ? false : minorSplitLine },
splitLine: { show: isSmallChart ? false : gridlines },
splitLine: { show: !isSmallChart },
axisLabel: {
show: !isMicroChart,
showMinLabel: !isMicroChart,
@@ -1342,7 +1338,7 @@ export default function transformProps(
yAxisFormat,
),
},
axisTick: { show: isSmallChart ? false : axisTicks },
axisTick: { show: !isSmallChart },
scale: truncateYAxis,
name: isSmallChart ? undefined : yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
@@ -73,8 +73,6 @@ export type EchartsTimeseriesFormData = QueryFormData & {
metrics: QueryFormMetric[];
minorSplitLine: boolean;
minorTicks: boolean;
gridlines: boolean;
axisTicks: boolean;
opacity: number;
orderDesc: boolean;
rowLimit: number;
@@ -495,28 +495,6 @@ export const minorTicks: ControlSetItem = {
},
};
export const axisTicks: ControlSetItem = {
name: 'axisTicks',
config: {
type: 'CheckboxControl',
label: t('Axis ticks'),
default: true,
renderTrigger: true,
description: t('Show the main ticks on axes.'),
},
};
export const gridlines: ControlSetItem = {
name: 'gridlines',
config: {
type: 'CheckboxControl',
label: t('Gridlines'),
default: true,
renderTrigger: true,
description: t('Draw split lines for the main value axis ticks.'),
},
};
export const forceCategorical: ControlSetItem = {
name: 'forceCategorical',
config: {
@@ -47,9 +47,6 @@ const getCrossFilterDataMask =
) =>
(value: string) => {
const selected = Object.values(selectedValues);
if (!labelMap[value] && !selected.includes(value)) {
return undefined;
}
let values: string[];
if (selected.includes(value)) {
values = selected.filter(v => v !== value);
@@ -116,8 +116,6 @@ const formData: EchartsMixedTimeseriesFormData = {
markerSizeB: 0,
minorSplitLine: false,
minorTicks: false,
gridlines: true,
axisTicks: true,
opacity: 0,
opacityB: 0,
orderDesc: false,
@@ -1511,56 +1509,3 @@ describe('EchartsMixedTimeseries tooltip truncation', () => {
expect(html).not.toContain(longSeriesName);
});
});
function transformWithChrome(
overrides: Partial<EchartsMixedTimeseriesFormData>,
) {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: { ...formData, ...overrides },
queriesData,
});
const { echartOptions } = transformProps(chartProps);
return {
xAxis: echartOptions.xAxis as any,
yAxis: echartOptions.yAxis as any[],
};
}
test('draws gridlines and axis ticks when both are enabled', () => {
const { xAxis, yAxis } = transformWithChrome({});
expect(yAxis[0].splitLine.show).toBe(true);
// Both axes keep ECharts' own default, which the Mixed chart never overrode.
expect(yAxis[0].axisTick.show).toBe('auto');
expect(xAxis.axisTick.show).toBe('auto');
});
test('hides the gridlines on the primary axis', () => {
const { xAxis, yAxis } = transformWithChrome({ gridlines: false });
expect(yAxis[0].splitLine.show).toBe(false);
// The secondary axis never draws gridlines, so the two grids cannot double up.
expect(yAxis[1].splitLine.show).toBe(false);
expect(xAxis.splitLine.show).toBe(false);
});
test('never turns the secondary axis gridlines on', () => {
const { xAxis, yAxis } = transformWithChrome({ gridlines: true });
expect(yAxis[0].splitLine.show).toBe(true);
expect(yAxis[1].splitLine.show).toBe(false);
expect(xAxis.splitLine).toBeUndefined();
});
test('hides the ticks on the x axis and both y axes', () => {
const { xAxis, yAxis } = transformWithChrome({ axisTicks: false });
expect(xAxis.axisTick.show).toBe(false);
expect(yAxis[0].axisTick.show).toBe(false);
expect(yAxis[1].axisTick.show).toBe(false);
});
@@ -2766,76 +2766,3 @@ describe('tooltip for metrics whose labels end in forecast suffixes', () => {
expect(html).toContain('>ci<');
});
});
test('shows gridlines and axis ticks by default', () => {
const { echartOptions } = transformProps(createTestChartProps({}));
const xAxis = echartOptions.xAxis as any;
const yAxis = echartOptions.yAxis as any;
expect(yAxis.splitLine.show).toBe(true);
expect(yAxis.axisTick.show).toBe(true);
// Left to ECharts, which draws no ticks on a banded category axis. Forcing
// true would add ticks the chart does not have today.
expect(xAxis.axisTick.show).toBe('auto');
});
test('hides gridlines without touching the minor split lines', () => {
const { echartOptions } = transformProps(
createTestChartProps({ formData: { gridlines: false } }),
);
const yAxis = echartOptions.yAxis as any;
expect(yAxis.splitLine.show).toBe(false);
expect(yAxis.minorSplitLine.show).toBe(DEFAULT_FORM_DATA.minorSplitLine);
expect(yAxis.axisTick.show).toBe(true);
});
test('leaves the category axis split lines alone until gridlines are turned off', () => {
const shown = transformProps(createTestChartProps({}));
// Writing show:true here would draw gridlines on axis types that default to
// none, so the key is only ever added to hide them.
expect((shown.echartOptions.xAxis as any).splitLine).toBeUndefined();
const hidden = transformProps(
createTestChartProps({ formData: { gridlines: false } }),
);
expect((hidden.echartOptions.xAxis as any).splitLine.show).toBe(false);
});
test('hides the ticks on both axes', () => {
const { echartOptions } = transformProps(
createTestChartProps({ formData: { axisTicks: false } }),
);
expect((echartOptions.yAxis as any).axisTick.show).toBe(false);
expect((echartOptions.xAxis as any).axisTick.show).toBe(false);
expect((echartOptions.yAxis as any).splitLine.show).toBe(true);
});
test('keeps gridlines and ticks off on a compact chart even when both are enabled', () => {
const { echartOptions } = transformProps(
createTestChartProps({
height: TIMESERIES_CONSTANTS.compactChartHeight - 1,
formData: { gridlines: true, axisTicks: true },
}),
);
const yAxis = echartOptions.yAxis as any;
expect(yAxis.splitLine.show).toBe(false);
expect(yAxis.axisTick.show).toBe(false);
});
test('applies gridlines to the value axis after a horizontal orientation swaps it', () => {
const { echartOptions } = transformProps(
createTestChartProps({
formData: {
orientation: OrientationType.Horizontal,
gridlines: false,
},
}),
);
// The transform swaps the axes for a horizontal chart, so the value axis —
// and the gridlines belonging to it — end up on xAxis.
expect((echartOptions.xAxis as any).splitLine.show).toBe(false);
});
@@ -180,46 +180,3 @@ test('cross-filter does nothing when emitCrossFilters is false', () => {
expect(setDataMask).not.toHaveBeenCalled();
});
test('cross-filter does nothing when name is missing from labelMap', () => {
const setDataMask = jest.fn();
const props = buildProps({
groupby: ['topics'],
labelMap: {
cancellations: ['cancellations'],
},
selectedValues: {},
setDataMask,
});
const handlers = allEventHandlers(props);
// e.g. Pie "Other" category is not present in labelMap
handlers.click({ name: 'Other' });
expect(setDataMask).not.toHaveBeenCalled();
});
test('cross-filter still deselects a previously selected value that is missing from labelMap', () => {
const setDataMask = jest.fn();
const props = buildProps({
groupby: ['topics'],
labelMap: {
cancellations: ['cancellations'],
},
// "Other" was selected before it dropped out of labelMap (e.g. a stale
// cross-filter from an earlier render or dashboard state).
selectedValues: { 0: 'Other' },
setDataMask,
});
const handlers = allEventHandlers(props);
handlers.click({ name: 'Other' });
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [],
},
}),
);
});
@@ -30,7 +30,7 @@
},
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.29.0",
"mapbox-gl": "^3.28.1",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
@@ -462,14 +462,13 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// only take relevant page size options
const pageSizeOptions = useMemo(() => {
const getServerPagination = (n: number) =>
n <= Math.max(rowCount, serverPageLength);
const getServerPagination = (n: number) => n <= rowCount;
return (
serverPagination ? SERVER_PAGE_SIZE_OPTIONS : PAGE_SIZE_OPTIONS
).filter(([n]) =>
serverPagination ? getServerPagination(n) : n <= 2 * data.length,
) as SizeOption[];
}, [data.length, rowCount, serverPageLength, serverPagination]);
}, [data.length, rowCount, serverPagination]);
const getValueRange = useCallback(
function getValueRange(key: string, alignPositiveNegative: boolean) {
@@ -355,31 +355,25 @@ describe('SqlEditor', () => {
}),
);
// findByRole('button', { name }) walks every stylesheet rule via nwsapi to
// compute the accessible name, which can crash on an unrelated antd Tabs
// "more" button style; findByLabelText matches the same aria-label without
// that traversal.
test('enables the save dataset button when the latest query succeeded', async () => {
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Success,
});
expect(await findByLabelText('Save dataset')).toBeEnabled();
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
});
test('disables the save dataset button when the latest query failed', async () => {
const { findByLabelText } = setupWithLatestQuery({
const { findByRole } = setupWithLatestQuery({
state: QueryState.Failed,
results: undefined,
});
expect(await findByLabelText('Save dataset')).toBeDisabled();
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
});
test('disables the save dataset button when the results are not loaded', async () => {
const { findByLabelText } = setupWithLatestQuery({
const { findByRole } = setupWithLatestQuery({
state: QueryState.Success,
results: undefined,
});
expect(await findByLabelText('Save dataset')).toBeDisabled();
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
});
test('renders an Extension if provided', async () => {
@@ -67,23 +67,14 @@ async function renderAndWait(props = mockedProps) {
container = renderedContainer;
}
// A modal that wasn't handed an `etag` reads the dataset itself and can't save
// until that lands, so tests must wait before acting on the Save button.
async function waitForSaveEnabled() {
await waitFor(() =>
expect(screen.getByTestId('datasource-modal-save')).toBeEnabled(),
);
}
beforeEach(async () => {
beforeEach(() => {
fetchMock.clearHistory().removeRoutes();
cleanup();
renderAndWait();
fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
fetchMock.get(GET_DATASOURCE_ENDPOINT, { result: {} });
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
await waitForSaveEnabled();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -127,7 +118,6 @@ describe('DatasourceModal', () => {
onDatasourceSave:
onDatasourceSave as unknown as typeof mockedProps.onDatasourceSave,
});
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
const okButton = await screen.findByRole('button', { name: 'Confirm' });
@@ -161,96 +151,6 @@ describe('DatasourceModal', () => {
putSpy.mockRestore();
});
test('sends the supplied etag as If-Match so a stale save is refused', async () => {
cleanup();
renderAndWait({ ...mockedProps, etag: '"v1"' } as typeof mockedProps);
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
await waitFor(() => {
const putCall = fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put');
expect(
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
).toEqual('"v1"');
});
});
test('reads the etag from the dataset when the caller supplies none', async () => {
cleanup();
fetchMock.clearHistory().removeRoutes();
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
fetchMock.get(GET_DATASOURCE_ENDPOINT, {
body: { result: {} },
headers: { ETag: '"v2"' },
});
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
// The form is seeded from the same read as the validator, so saving is
// unavailable until it lands.
expect(screen.getByTestId('datasource-modal-save')).toBeDisabled();
await screen.findByTestId('datasource-editor');
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
await waitFor(() => {
const putCall = fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put');
expect(
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
).toEqual('"v2"');
});
});
test('never saves unguarded while the validator read is in flight', async () => {
cleanup();
fetchMock.clearHistory().removeRoutes();
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
// A read that never resolves: the save path must stay closed rather than
// fall through to an unconditional PUT.
fetchMock.get(GET_DATASOURCE_ENDPOINT, new Promise(() => {}));
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
const saveButton = await screen.findByTestId('datasource-modal-save');
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(
fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put'),
).toBeUndefined();
});
test('shows a conflict dialog instead of a generic error on 412', async () => {
const putSpy = jest
.spyOn(SupersetClient, 'put')
.mockRejectedValue(new Response('', { status: 412 }));
try {
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
const conflictElements = await screen.findAllByText(
'Dataset changed since you opened it',
);
expect(conflictElements.length).toBeGreaterThan(0);
expect(
screen.queryByText('Error saving dataset'),
).not.toBeInTheDocument();
} finally {
putSpy.mockRestore();
}
});
test('shows sync columns checkbox when SQL changes', async () => {
cleanup();
const datasourceWithSQL = {
@@ -263,24 +163,15 @@ describe('DatasourceModal', () => {
};
const { rerender } = render(
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
{ store, useRouter: true },
);
// Update with modified SQL
rerender(
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -317,24 +208,15 @@ describe('DatasourceModal', () => {
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
const { rerender } = render(
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
{ store, useRouter: true },
);
// Update with modified SQL to trigger checkbox
rerender(
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -387,24 +269,15 @@ describe('DatasourceModal', () => {
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
const { rerender } = render(
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
{ store, useRouter: true },
);
// Update with modified SQL to trigger checkbox
rerender(
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -21,7 +21,6 @@ import {
screen,
fireEvent,
act,
waitFor,
defaultStore as store,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
@@ -73,9 +72,6 @@ test('DatasourceModal - should handle sync columns state without imperative moda
render(<DatasourceModal {...mockedProps} />, { store });
const saveButton = screen.getByTestId('datasource-modal-save');
// The modal fetches the current dataset version on open; save stays disabled
// until that settles
await waitFor(() => expect(saveButton).toBeEnabled());
// This should not throw any DOM errors
await act(async () => {
@@ -33,14 +33,12 @@ import {
Icons,
Button,
Checkbox,
Loading,
Modal,
AsyncEsmComponent,
} from '@superset-ui/core/components';
import withToasts from 'src/components/MessageToasts/withToasts';
import { ErrorMessageWithStackTrace } from 'src/components';
import type { DatasetObject } from 'src/features/datasets/types';
import { withCertificationFields } from '../utils';
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
import type { DatasourceModalProps } from '../types';
@@ -93,18 +91,12 @@ export function buildExtraJsonObject(
const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
addSuccessToast,
datasource,
etag,
onDatasourceSave,
onHide,
show,
}) => {
const theme = useTheme();
const [currentDatasource, setCurrentDatasource] = useState(datasource);
// SQL of the server snapshot the form started from. The caller's, unless
// this modal read the dataset itself — then "did the SQL change?" has to be
// asked against the snapshot the payload is actually built from.
const [seededSql, setSeededSql] = useState<string | undefined>();
const [versionEtag, setVersionEtag] = useState(etag);
const [syncColumns, setSyncColumns] = useState(false);
const currencies = useSelector<
{
@@ -119,52 +111,6 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const [isEditing, setIsEditing] = useState<boolean>(false);
const [modal, contextHolder] = Modal.useModal();
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
const [isLoadingDatasource, setIsLoadingDatasource] = useState(false);
// Callers that read the dataset themselves (the dataset list) hand down the
// ETag of that read. The rest — Explore, where `datasource` comes from the
// page's bootstrap state — read it here, and must seed the form from the
// *same* response: a payload built from an older snapshot than the ETag
// guarding it would still be accepted, and would still clobber.
useEffect(() => {
setVersionEtag(etag);
if (etag || !show || !datasource.id) {
return undefined;
}
let cancelled = false;
setIsLoadingDatasource(true);
SupersetClient.get({
endpoint: `/api/v1/dataset/${datasource.id}`,
})
.then(({ json, response }) => {
if (cancelled) {
return;
}
const seeded = {
...datasource,
...json.result,
columns: withCertificationFields(json.result.columns),
};
setSeededSql(seeded.sql);
setCurrentDatasource(seeded);
setVersionEtag(response.headers.get('ETag') ?? undefined);
})
.catch(() => {
// The read failed outright, so there is no fresher snapshot to edit
// and no validator to send. Fall back to the caller's snapshot and an
// unconditional save, which is what this modal did before the guard.
})
.finally(() => {
if (!cancelled) {
setIsLoadingDatasource(false);
}
});
return () => {
cancelled = true;
};
}, [datasource.id, etag, show]);
const baselineSql = seededSql ?? datasource.sql;
const buildPayload = (datasource: Record<string, any>) => {
const payload: Record<string, any> = {
table_name: datasource.table_name,
@@ -251,13 +197,11 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
await SupersetClient.put({
endpoint: `/api/v1/dataset/${currentDatasource.id}?override_columns=${syncColumns}`,
jsonPayload: buildPayload(currentDatasource),
...(versionEtag ? { headers: { 'If-Match': versionEtag } } : {}),
});
const { json, response } = await SupersetClient.get({
const { json } = await SupersetClient.get({
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
});
setVersionEtag(response.headers.get('ETag') ?? undefined);
addSuccessToast(t('The dataset has been saved'));
// eslint-disable-next-line no-param-reassign
@@ -269,19 +213,6 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
onHide();
} catch (response) {
setIsSaving(false);
if ((response as Response)?.status === 412) {
modal.error({
title: t('Dataset changed since you opened it'),
okButtonProps: { danger: true, className: 'btn-danger' },
content: t(
'Someone else, or another one of your browser tabs, saved this ' +
'dataset after you opened it. Saving now would undo those ' +
'changes, so it was cancelled. Copy your edits, close this ' +
'dialog, and reopen the dataset to reapply them.',
),
});
return;
}
const error = await getClientErrorObject(response);
let errorResponse: SupersetError | undefined;
let errorText: string | undefined;
@@ -333,7 +264,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
here may affect other charts
in undesirable ways.`)}
/>
{baselineSql !== currentDatasource.sql && (
{datasource.sql !== currentDatasource.sql && (
<div
css={theme => ({
marginBottom: theme.marginMD,
@@ -367,14 +298,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
{t('Are you sure you want to save and apply changes?')}
</div>
),
[currentDatasource.sql, baselineSql, syncColumns],
[currentDatasource.sql, datasource.sql, syncColumns],
);
useEffect(() => {
if (baselineSql !== currentDatasource.sql) {
if (datasource.sql !== currentDatasource.sql) {
setSyncColumns(true);
}
}, [baselineSql, currentDatasource.sql]);
}, [datasource.sql, currentDatasource.sql]);
const onClickSave = () => {
setConfirmModalOpen(true);
@@ -425,7 +356,6 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
onClick={onClickSave}
disabled={
isSaving ||
isLoadingDatasource ||
errors.length > 0 ||
currentDatasource.is_managed_externally
}
@@ -451,18 +381,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
}}
draggable
>
{isLoadingDatasource ? (
<Loading />
) : (
<DatasourceEditor
showLoadingForImport
height={500}
datasource={currentDatasource}
onChange={onDatasourceChange}
setIsEditing={setIsEditing}
currencies={currencies}
/>
)}
<DatasourceEditor
showLoadingForImport
height={500}
datasource={currentDatasource}
onChange={onDatasourceChange}
setIsEditing={setIsEditing}
currencies={currencies}
/>
{contextHolder}
<Modal
title={t('Confirm save')}
@@ -20,5 +20,4 @@ import ChangeDatasourceModal from './ChangeDatasourceModal';
import DatasourceModal from './DatasourceModal';
export { ChangeDatasourceModal, DatasourceModal };
export { withCertificationFields } from './utils';
export type { DatasourceModalProps, ChangeDatasourceModalProps } from './types';
@@ -29,12 +29,6 @@ export interface DatasourceModalProps {
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
datasource: DatasetObject;
/**
* ETag of the dataset read the form was seeded from. Replayed as `If-Match`
* on save so a stale form can't clobber a newer write. Fetched by the modal
* when the caller doesn't already have one.
*/
etag?: string;
onChange: () => {};
onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
onHide: () => {};
@@ -27,7 +27,6 @@ import { nanoid } from 'nanoid';
import { SupersetClient } from '@superset-ui/core';
import { tn } from '@apache-superset/core/translation';
import rison from 'rison';
import type { ColumnObject } from 'src/features/datasets/types';
// Type definitions
@@ -249,29 +248,3 @@ export async function fetchSyncedColumns(
const { json } = await SupersetClient.get({ endpoint, signal });
return json as ColumnMetadata[];
}
/**
* Lift each column's certification out of its `extra` JSON into the flat
* fields the datasource editor binds to.
*/
export function withCertificationFields(columns: ColumnObject[] = []) {
return columns.map(column => {
// Malformed `extra` must not take out the whole column list, the way an
// uncaught parse would — same fallback as `hydrateMetricExtra`.
let parsedExtra;
try {
parsedExtra = JSON.parse(column.extra || '{}') || {};
} catch {
parsedExtra = {};
}
const {
certification: { details = '', certified_by: certifiedBy = '' } = {},
} = parsedExtra;
return {
...column,
certification_details: details || '',
certified_by: certifiedBy || '',
is_certified: details || certifiedBy,
};
});
}
@@ -53,22 +53,6 @@ test('RowCountLabel renders limit with danger and tooltip', async () => {
expect(tooltip).toHaveTextContent('The row limit');
});
test('RowCountLabel uses a caller-provided limitReachedMessage instead of the default', async () => {
render(
<RowCountLabel
rowcount={100}
limit={100}
limitReachedMessage="Custom limit message"
/>,
);
const expectedText = '100 rows';
expect(screen.getByText(expectedText)).toBeInTheDocument();
userEvent.hover(screen.getByText(expectedText));
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent('Custom limit message');
expect(tooltip).not.toHaveTextContent('The row limit set for the chart');
});
test('RowCountLabel renders loading', () => {
render(<RowCountLabel loading />);
const expectedText = 'Loading...';
@@ -26,9 +26,6 @@ type RowCountLabelProps = {
limit?: number;
loading?: boolean;
label?: JSX.Element;
// Overrides the default "chart" wording for panes (e.g. samples) where the
// limit reached isn't the chart's own row_limit.
limitReachedMessage?: React.ReactNode;
};
const limitReachedMsg = t(
@@ -36,13 +33,7 @@ const limitReachedMsg = t(
);
export default function RowCountLabel(props: RowCountLabelProps) {
const {
rowcount = 0,
limit = null,
loading,
label,
limitReachedMessage,
} = props;
const { rowcount = 0, limit = null, loading, label } = props;
const limitReached = limit && rowcount >= limit;
const type =
limitReached || (rowcount === 0 && !loading) ? 'error' : 'default';
@@ -59,10 +50,7 @@ export default function RowCountLabel(props: RowCountLabelProps) {
</Label>
);
return limitReached ? (
<Tooltip
id="tt-rowcount-tooltip"
title={<span>{limitReachedMessage ?? limitReachedMsg}</span>}
>
<Tooltip id="tt-rowcount-tooltip" title={<span>{limitReachedMsg}</span>}>
{label || labelText}
</Tooltip>
) : (
@@ -312,7 +312,7 @@ export function handleComponentDrop(dropResult: DropResult) {
source &&
!(
// ensure it has moved
destination.id === source.id && destination.index === source.index
(destination.id === source.id && destination.index === source.index)
)
) {
dispatch(moveComponent(dropResult));
@@ -117,7 +117,7 @@ const StyledDiv = styled.div`
${
isMobileConsumptionEnabled()
? `@media (max-width: ${theme.screenSMMax}px) {
.slice-header .header-title {
[data-test='slice-header'] .header-title {
-webkit-line-clamp: unset;
display: block;
white-space: normal;
@@ -79,15 +79,6 @@ type PropertiesModalProps = {
addSuccessToast: (message: string) => void;
addDangerToast: (message: string) => void;
onlyApply?: boolean;
renderExtraFields?: (context: {
assetId: number;
assetType: 'dashboard';
accessorCount: number;
}) => {
content: React.ReactNode;
saveDisabled?: boolean;
saveTooltip?: string;
};
};
type DashboardInfo = {
@@ -116,7 +107,6 @@ const PropertiesModal = ({
onlyApply = false,
onSubmit = () => {},
show = false,
renderExtraFields,
}: PropertiesModalProps) => {
const dispatch = useDispatch();
const [form] = Form.useForm();
@@ -133,17 +123,6 @@ const PropertiesModal = ({
});
const [editors, setEditors] = useState<Subject[]>([]);
const [viewers, setViewers] = useState<Subject[]>([]);
const extraFields = useMemo(
() =>
renderExtraFields?.({
assetId: dashboardId,
assetType: 'dashboard',
accessorCount: editors.length + viewers.length,
}),
[renderExtraFields, dashboardId, editors.length, viewers.length],
);
const saveLabel = onlyApply ? t('Apply') : t('Save');
const [tags, setTags] = useState<TagType[]>([]);
const [customCss, setCustomCss] = useState('');
@@ -719,21 +698,15 @@ const PropertiesModal = ({
}}
title={t('Dashboard properties')}
isEditMode
saveDisabled={
dashboardInfo?.isManagedExternally ||
hasErrors ||
extraFields?.saveDisabled
}
saveDisabled={dashboardInfo?.isManagedExternally || hasErrors}
saveLoading={isApplying}
contentLoading={isLoading}
errorTooltip={
extraFields?.saveDisabled && extraFields?.saveTooltip
? extraFields.saveTooltip
: dashboardInfo?.isManagedExternally
? t(
"This dashboard is managed externally, and can't be edited in Superset",
)
: errorTooltip
dashboardInfo?.isManagedExternally
? t(
"This dashboard is managed externally, and can't be edited in Superset",
)
: errorTooltip
}
saveText={saveLabel}
wrapProps={{ 'data-test': 'properties-edit-modal' }}
@@ -796,7 +769,6 @@ const PropertiesModal = ({
onChangeViewers={handleOnChangeViewers}
onChangeTags={handleChangeTags}
onClearTags={handleClearTags}
renderExtraFields={extraFields}
/>
),
},
@@ -38,11 +38,6 @@ interface AccessSectionProps {
onChangeViewers: (viewers: SubjectPickerValue[]) => void;
onChangeTags: (tags: { label: string; value: number }[]) => void;
onClearTags: () => void;
renderExtraFields?: {
content: React.ReactNode;
saveDisabled?: boolean;
saveTooltip?: string;
};
}
const AccessSection = ({
@@ -54,7 +49,6 @@ const AccessSection = ({
onChangeViewers,
onChangeTags,
onClearTags,
renderExtraFields,
}: AccessSectionProps) => {
const enableViewers = isFeatureEnabled(FeatureFlag.EnableViewers);
@@ -140,7 +134,6 @@ const AccessSection = ({
/>
</ModalFormField>
)}
{renderExtraFields?.content}
</>
);
};
@@ -208,18 +208,6 @@ test('Should render', () => {
expect(screen.getByTestId('slice-header')).toBeInTheDocument();
});
test('Should expose a class hook, not just data-test, for fullscreen styling', () => {
const props = createProps();
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
// The production build strips data-test attributes, so CSS that targets the
// header must hang off a class instead.
expect(screen.getByTestId('slice-header')).toHaveClass('slice-header');
});
test('Should render - default props', () => {
const props = createProps();
@@ -270,11 +270,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
);
return (
<ChartHeaderStyles
className="slice-header"
data-test="slice-header"
ref={ref}
>
<ChartHeaderStyles data-test="slice-header" ref={ref}>
<div className="header-title" ref={headerRef}>
<Tooltip title={headerTooltip}>
{/* this div ensures the hover event triggers correctly and prevents flickering */}
@@ -23,7 +23,7 @@ import {
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { FeatureFlag, VizType, getExtensionsRegistry } from '@superset-ui/core';
import { FeatureFlag, VizType } from '@superset-ui/core';
import mockState from 'spec/fixtures/mockState';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import downloadAsImage from 'src/utils/downloadAsImage';
@@ -165,9 +165,6 @@ beforeEach(() => {
afterEach(() => {
Reflect.deleteProperty(document, 'fullscreenElement');
// TypedRegistry has no remove(); reset to a no-op so a registered slot does
// not leak into other tests (the empty array is guarded, so nothing injects).
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
});
test('Should render', () => {
@@ -176,58 +173,6 @@ test('Should render', () => {
expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument();
});
test('Injects dashboard.slice.header.menu items at the top of the menu', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => [
{ key: 'custom-ext', label: 'Custom Menu Extension' },
]);
renderWrapper();
openMenu();
const injected = screen.getByText('Custom Menu Extension');
expect(injected).toBeInTheDocument();
// Sits above the built-in entries.
const forceRefresh = screen.getByText('Force refresh');
expect(
injected.compareDocumentPosition(forceRefresh) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
test('Injects nothing when dashboard.slice.header.menu returns no items', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
renderWrapper();
openMenu();
expect(screen.queryByText('Custom Menu Extension')).not.toBeInTheDocument();
// The menu still renders its built-in entries unchanged (no dangling divider
// is added since the empty array is guarded).
expect(screen.getByText('Force refresh')).toBeInTheDocument();
});
test('Menu survives a dashboard.slice.header.menu extension that throws', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => {
throw new Error('boom');
});
renderWrapper();
openMenu();
// The throw is isolated: the built-in menu still renders.
expect(screen.getByText('Force refresh')).toBeInTheDocument();
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
});
test('Injects nothing when the extension returns a non-array', () => {
getExtensionsRegistry().set(
'dashboard.slice.header.menu',
// JS registrations bypass the MenuItem[] type; a bad return must not crash.
(() => undefined) as never,
);
renderWrapper();
openMenu();
expect(screen.getByText('Force refresh')).toBeInTheDocument();
});
test('Should render default props', () => {
const props = createProps();
@@ -19,13 +19,20 @@
import { css, SupersetTheme } from '@apache-superset/core/theme';
export const fullscreenStyles = (theme: SupersetTheme) => css`
.dashboard-component-chart-holder:fullscreen {
[data-test='dashboard-component-chart-holder']:fullscreen {
background-color: ${theme.colorBgBase};
width: 100vw;
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: ${theme.sizeUnit * 4}px;
overflow: visible;
position: relative;
pointer-events: auto;
z-index: ${theme.zIndexPopupBase};
opacity: 1;
visibility: visible;
/* Ensure children take up available space */
.dashboard-chart,
@@ -51,8 +58,13 @@ export const fullscreenStyles = (theme: SupersetTheme) => css`
}
}
/* Keep the header above the chart it shares the fullscreen layer with */
.dashboard-component-chart-holder:fullscreen .slice-header {
/* Interaction and Header fixes */
[data-test='dashboard-component-chart-holder']:fullscreen * {
pointer-events: auto;
}
[data-test='dashboard-component-chart-holder']:fullscreen
[data-test='slice-header'] {
z-index: ${theme.zIndexPopupBase};
position: relative;
}
@@ -34,13 +34,11 @@ import {
isFeatureEnabled,
FeatureFlag,
getChartMetadataRegistry,
getExtensionsRegistry,
VizType,
BinaryQueryObjectFilterClause,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme, styled } from '@apache-superset/core/theme';
import { useSelector } from 'react-redux';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
@@ -167,8 +165,6 @@ const queueChartResize = () => {
}, 300);
};
const extensionsRegistry = getExtensionsRegistry();
const SliceHeaderControls = (
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
) => {
@@ -518,26 +514,6 @@ const SliceHeaderControls = (
},
];
const sliceHeaderMenuExtension = extensionsRegistry.get(
'dashboard.slice.header.menu',
);
if (sliceHeaderMenuExtension) {
// Isolate the extension: a bad registration (throwing, or returning a
// non-array) must not take down the whole dashboard render.
try {
const extensionItems = sliceHeaderMenuExtension({
sliceId: slice.slice_id,
sliceName: slice.slice_name,
dashboardId,
});
if (Array.isArray(extensionItems) && extensionItems.length) {
newMenuItems.unshift(...extensionItems, { type: 'divider' });
}
} catch (error) {
logging.error('dashboard.slice.header.menu extension failed', error);
}
}
if (slice.description) {
newMenuItems.push({
key: MenuKeys.ToggleChartDescription,
@@ -337,17 +337,13 @@ const ChartHolder = ({
)}
>
<AntdThemeProvider
getPopupContainer={(triggerNode?: HTMLElement) => {
// Only the fullscreen element's subtree is painted, so popups
// have to be portaled into it rather than to document.body.
// Resolve it directly instead of matching a selector: the
// production build strips data-test attributes.
const fullscreenElement =
document.fullscreenElement as HTMLElement | null;
return triggerNode && fullscreenElement?.contains(triggerNode)
? fullscreenElement
: document.body;
}}
getPopupContainer={(triggerNode: HTMLElement) =>
document.fullscreenElement
? (triggerNode?.closest?.(
'[data-test="dashboard-component-chart-holder"]',
) as HTMLElement) || document.body
: document.body
}
>
{!editMode && (
<AnchorLink
@@ -97,29 +97,3 @@ test('does not render DeckglLayerVisibilityTooltip for standard filter type', ()
screen.queryByTestId('deckgl-layer-visibility-tooltip-icon'),
).not.toBeInTheDocument();
});
test('does not mark a defaultToFirstItem-only filter as required', () => {
render(
<FilterControl
filter={{
...nativeFilter,
controlValues: { defaultToFirstItem: true },
}}
onFilterSelectionChange={jest.fn()}
/>,
);
expect(screen.queryByText('*')).not.toBeInTheDocument();
});
test('marks an enableEmptyFilter filter as required', () => {
render(
<FilterControl
filter={{
...nativeFilter,
controlValues: { enableEmptyFilter: true },
}}
onFilterSelectionChange={jest.fn()}
/>,
);
expect(screen.getByText('*')).toBeInTheDocument();
});
@@ -65,7 +65,9 @@ const FilterControl = ({
isFilterInScope(filter) &&
checkIsMissingRequiredValue(filter, filter.dataMask?.filterState);
const validateStatus = isMissingRequiredValue ? 'error' : undefined;
const isRequired = !!filter.controlValues?.enableEmptyFilter;
const isRequired =
!!filter.controlValues?.enableEmptyFilter ||
!!filter.controlValues?.defaultToFirstItem;
const inverseSelection = !!filter.controlValues?.inverseSelection;
const {
@@ -211,16 +211,6 @@ test('checkIsMissingRequiredValue returns false for non-required filter with und
expect(checkIsMissingRequiredValue(filter, filterState)).toBe(false);
});
test('checkIsMissingRequiredValue returns false when only defaultToFirstItem is set', () => {
const filter = createFilter('test-filter', {
enableEmptyFilter: false,
controlValues: { defaultToFirstItem: true },
});
expect(checkIsMissingRequiredValue(filter, { value: null })).toBe(false);
expect(checkIsMissingRequiredValue(filter, { value: undefined })).toBe(false);
});
test('checkIsMissingRequiredValue returns falsy for filter without controlValues', () => {
const filter = { id: 'test-filter' } as Filter;
const filterState: FilterState = { value: undefined };
@@ -309,48 +299,6 @@ test('checkIsApplyDisabled returns true when required filter is missing value in
);
});
test('checkIsApplyDisabled enables Apply after clearing a cascading defaultToFirstItem child', () => {
// Regression: a child filter that is dependent on a parent and configured with
// "Select first filter value by default" but NOT "Filter value is required"
// must stay clearable — clearing it may not disable Apply.
const parent = createFilter('parent', {
enableEmptyFilter: true,
controlValues: { defaultToFirstItem: true },
});
const child = createFilter('child', {
enableEmptyFilter: false,
controlValues: { defaultToFirstItem: true },
});
const dataMaskSelected: DataMaskStateWithId = {
parent: {
id: 'parent',
filterState: { value: ['USA'] },
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
},
child: {
id: 'child',
filterState: { value: null },
extraFormData: {},
},
};
const dataMaskApplied: DataMaskStateWithId = {
parent: {
id: 'parent',
filterState: { value: ['USA'] },
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
},
child: {
id: 'child',
filterState: { value: ['CA'] },
extraFormData: createExtraFormDataWithFilter('state', ['CA']),
},
};
expect(
checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, [parent, child]),
).toBe(false);
});
test('checkIsApplyDisabled enables Apply when Selected has a filter value not yet in Applied', () => {
// Regression: when a required filter's default isn't applied (Applied missing
// the entry) and the user types a value, Selected gains an entry Applied
@@ -48,10 +48,9 @@ export const checkIsMissingRequiredValue = (
filter: FilterElement,
filterState?: FilterState,
) => {
// Only `enableEmptyFilter` ("Filter value is required") makes a value
// mandatory. `defaultToFirstItem` merely seeds an initial selection, so a
// filter cleared by the user must stay clearable, with Apply enabled.
const isRequired = !!filter.controlValues?.enableEmptyFilter;
const isRequired =
!!filter.controlValues?.enableEmptyFilter ||
!!filter.controlValues?.defaultToFirstItem;
if (!isRequired) return false;
+1 -1
View File
@@ -126,7 +126,7 @@ function fillNativeFilters(
!(
// Treat all-null arrays (range filters use [null, null] as their
// canonical cleared value) and empty arrays as "no value".
Array.isArray(loadedValue) && loadedValue.every(v => v === null)
(Array.isArray(loadedValue) && loadedValue.every(v => v === null))
);
const loadedHasExtraFormData =
!!loaded?.extraFormData && Object.keys(loaded.extraFormData).length > 0;
@@ -16,13 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
screen,
waitFor,
userEvent,
fireEvent,
} from 'spec/helpers/testing-library';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import Control, { ControlProps } from 'src/explore/components/Control';
const defaultProps: ControlProps = {
@@ -83,72 +77,3 @@ test('call setControlValue if isVisible is false', async () => {
expect(defaultProps.actions.setControlValue).toHaveBeenCalled(),
);
});
test('shows the description icon while the control is hovered', async () => {
render(
setup({
label: 'My checkbox',
description: 'Help text',
}),
);
expect(
screen.queryByRole('button', { name: 'Show info tooltip' }),
).not.toBeInTheDocument();
await userEvent.hover(screen.getByTestId('checkbox'));
expect(
screen.getByRole('button', { name: 'Show info tooltip' }),
).toBeInTheDocument();
await userEvent.unhover(screen.getByTestId('checkbox'));
expect(
screen.queryByRole('button', { name: 'Show info tooltip' }),
).not.toBeInTheDocument();
});
test('shows the description icon while the control has keyboard focus', () => {
render(
setup({
label: 'My checkbox',
description: 'Help text',
}),
);
expect(
screen.queryByRole('button', { name: 'Show info tooltip' }),
).not.toBeInTheDocument();
fireEvent.focus(screen.getByRole('checkbox'));
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
expect(infoIcon).toBeInTheDocument();
fireEvent.blur(screen.getByRole('checkbox'), { relatedTarget: infoIcon });
expect(
screen.getByRole('button', { name: 'Show info tooltip' }),
).toBeInTheDocument();
fireEvent.blur(infoIcon, { relatedTarget: document.body });
expect(
screen.queryByRole('button', { name: 'Show info tooltip' }),
).not.toBeInTheDocument();
});
test('keeps the description icon visible when the pointer leaves a focused control', () => {
render(
setup({
label: 'My checkbox',
description: 'Help text',
}),
);
fireEvent.focus(screen.getByRole('checkbox'));
expect(
screen.getByRole('button', { name: 'Show info tooltip' }),
).toBeInTheDocument();
fireEvent.mouseLeave(screen.getByTestId('checkbox'));
expect(
screen.getByRole('button', { name: 'Show info tooltip' }),
).toBeInTheDocument();
});
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, useCallback, useState, useEffect, FocusEvent } from 'react';
import { ReactNode, useCallback, useState, useEffect } from 'react';
import { isEqual } from 'lodash-es';
import {
ControlType,
@@ -70,18 +70,7 @@ export default function Control(props: ControlProps) {
} = props;
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const wasVisible = usePrevious(isVisible);
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
setFocused(false);
}
};
const onChange = useCallback(
(value: any, errors: any[]) => setControlValue(name, value, errors),
[name, setControlValue],
@@ -130,15 +119,9 @@ export default function Control(props: ControlProps) {
style={hidden ? { display: 'none' } : undefined}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={handleBlur}
>
<ErrorBoundary>
<ControlComponent
onChange={onChange}
hovered={hovered || focused}
{...props}
/>
<ControlComponent onChange={onChange} hovered={hovered} {...props} />
</ErrorBoundary>
</StyledControl>
);
@@ -1,112 +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 {
render,
screen,
userEvent,
fireEvent,
} from 'spec/helpers/testing-library';
import ControlHeader from './ControlHeader';
const description = 'This control filters the whole chart.';
test('does not render the description icon until the control is hovered', () => {
const { rerender } = render(
<ControlHeader
name="time_range"
label="Date Range"
description={description}
/>,
);
expect(
screen.queryByRole('button', { name: 'Show info tooltip' }),
).not.toBeInTheDocument();
rerender(
<ControlHeader
name="time_range"
label="Date Range"
description={description}
hovered
/>,
);
expect(
screen.getByRole('button', { name: 'Show info tooltip' }),
).toBeInTheDocument();
});
test('notifies onDescriptionHoverChange when the info icon is hovered', async () => {
const onDescriptionHoverChange = jest.fn();
render(
<ControlHeader
name="time_range"
label="Date Range"
description={description}
hovered
onDescriptionHoverChange={onDescriptionHoverChange}
/>,
);
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
await userEvent.hover(infoIcon);
expect(onDescriptionHoverChange).toHaveBeenCalledWith(true);
await userEvent.unhover(infoIcon);
expect(onDescriptionHoverChange).toHaveBeenCalledWith(false);
});
test('notifies onDescriptionHoverChange when the info icon is focused', () => {
const onDescriptionHoverChange = jest.fn();
render(
<ControlHeader
name="time_range"
label="Date Range"
description={description}
hovered
onDescriptionHoverChange={onDescriptionHoverChange}
/>,
);
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
fireEvent.focus(infoIcon);
expect(onDescriptionHoverChange).toHaveBeenCalledWith(true);
fireEvent.blur(infoIcon);
expect(onDescriptionHoverChange).toHaveBeenCalledWith(false);
});
test('activates tooltipOnClick from the keyboard', () => {
const tooltipOnClick = jest.fn();
render(
<ControlHeader
name="time_range"
label="Date Range"
description={description}
hovered
tooltipOnClick={tooltipOnClick}
/>,
);
fireEvent.keyDown(screen.getByRole('button', { name: 'Show info tooltip' }), {
key: 'Enter',
});
expect(tooltipOnClick).toHaveBeenCalledTimes(1);
});
@@ -38,7 +38,6 @@ export type ControlHeaderProps = {
tooltipOnClick?: () => void;
warning?: string;
danger?: string;
onDescriptionHoverChange?: (hovered: boolean) => void;
// Allow extra props from control spread patterns (e.g. {...this.props})
[key: string]: unknown;
};
@@ -72,7 +71,6 @@ const ControlHeader: FC<ControlHeaderProps> = ({
tooltipOnClick = () => {},
warning,
danger,
onDescriptionHoverChange,
}) => {
const theme = useTheme();
@@ -91,44 +89,24 @@ const ControlHeader: FC<ControlHeaderProps> = ({
position: absolute;
top: 50%;
right: 0;
z-index: 1;
padding-left: ${theme.sizeUnit}px;
transform: translate(100%, -50%);
white-space: nowrap;
pointer-events: auto;
`}
>
{description && (
<>
<span>
<Tooltip
id="description-tooltip"
title={description}
placement="top"
mouseLeaveDelay={0}
trigger={['hover', 'focus']}
>
{/* Same role="button" pattern as the label text: a real <button>
is not valid inside FormLabel's <label>. */}
<span
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
role="button"
tabIndex={0}
data-test={`${name}-description-icon`}
aria-label={t('Show info tooltip')}
onMouseEnter={() => onDescriptionHoverChange?.(true)}
onMouseLeave={() => onDescriptionHoverChange?.(false)}
onFocus={() => onDescriptionHoverChange?.(true)}
onBlur={() => onDescriptionHoverChange?.(false)}
<Icons.InfoCircleOutlined
css={iconStyles}
onClick={tooltipOnClick}
onKeyDown={handleKeyboardActivation(tooltipOnClick)}
css={css`
cursor: pointer;
`}
>
<Icons.InfoCircleOutlined css={iconStyles} />
</span>
/>
</Tooltip>{' '}
</>
</span>
)}
{renderTrigger && (
<span>
@@ -68,8 +68,6 @@ export const TableControls = ({
canDownload,
rowLimit,
rowLimitOptions,
effectiveRowLimit,
limitReachedMessage,
onRowLimitChange,
onDownloadCSV,
onDownloadXLSX,
@@ -113,19 +111,14 @@ export const TableControls = ({
value={rowLimit}
onChange={onRowLimitChange}
options={rowLimitOptions ?? []}
// Labelled as the applied limit to avoid a second row count next to RowCountLabel.
prefix={t('Limit')}
css={css`
min-width: 160px;
min-width: 110px;
`}
/>
)}
<RowCountLabel
rowcount={rowcount}
limit={effectiveRowLimit ?? rowLimit}
limitReachedMessage={limitReachedMessage}
loading={isLoading}
/>
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
<RowCountLabel rowcount={rowcount} loading={isLoading} />
)}
{canDownload && onDownloadCSV && onDownloadXLSX && (
<DownloadDropdown
onDownloadCSV={onDownloadCSV}
@@ -136,12 +136,6 @@ export const SamplesPane = ({
const columns = useGridColumns(colnames, coltypes, data);
const keywordFilter = useKeywordFilter(filterText);
// Samples aren't capped by a chart's row_limit, just this pane's own
// page-size selector, so RowCountLabel's default "chart" wording is wrong here.
const limitReachedMessage = t(
'The sample row limit was reached. This %s may contain more rows.',
datasetLabelLower(),
);
const handleInputChange = useCallback(
(input: string) => setFilterText(input),
@@ -167,7 +161,6 @@ export const SamplesPane = ({
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={handleRowLimitChange}
/>
<ErrorAlertWrapper>
@@ -204,7 +197,6 @@ export const SamplesPane = ({
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={handleRowLimitChange}
/>
<GridContainer>
@@ -56,8 +56,6 @@ export const SingleQueryResultPane = ({
columnDisplayNames,
rowLimit,
rowLimitOptions,
effectiveRowLimit,
limitReachedMessage,
onRowLimitChange,
onDownloadCSV,
onDownloadXLSX,
@@ -88,8 +86,6 @@ export const SingleQueryResultPane = ({
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={rowLimitOptions}
effectiveRowLimit={effectiveRowLimit}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={onRowLimitChange}
onDownloadCSV={onDownloadCSV}
onDownloadXLSX={onDownloadXLSX}
@@ -84,17 +84,6 @@ export const useResultsPane = ({
// Never exceed the chart's own row_limit
const effectiveRowLimit = Math.min(rowLimit, chartRowLimit);
// When this pane's own row-limit selector is stricter than the chart's
// row_limit, it - not the chart - is what caps the result, so
// RowCountLabel's default "chart" wording would be misleading (the chart's
// configured row_limit was never actually reached).
const limitReachedMessage =
rowLimit < chartRowLimit
? t(
'The row limit selected for this pane was reached. There may be more matching rows.',
)
: undefined;
const cappedFormData = useMemo(
() => ({ ...queryFormData, row_limit: effectiveRowLimit }),
[queryFormData, effectiveRowLimit],
@@ -247,8 +236,6 @@ export const useResultsPane = ({
columnDisplayNames={columnDisplayNames}
rowLimit={rowLimit}
rowLimitOptions={ROW_LIMIT_OPTIONS}
effectiveRowLimit={effectiveRowLimit}
limitReachedMessage={limitReachedMessage}
onRowLimitChange={handleRowLimitChange}
/>
</StyledDiv>
@@ -1,94 +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 {
act,
render,
screen,
sleep,
userEvent,
} from 'spec/helpers/testing-library';
import { GenericDataType } from '@apache-superset/core/common';
import {
TableControls,
ROW_LIMIT_OPTIONS,
} from '../components/DataTableControls';
import { TableControlsProps } from '../types';
const setup = (overrides: Partial<TableControlsProps> = {}) =>
render(
<TableControls
data={[]}
columnNames={['name']}
columnTypes={[GenericDataType.String]}
rowcount={0}
onInputChange={jest.fn()}
isLoading={false}
canDownload
rowLimit={100}
rowLimitOptions={ROW_LIMIT_OPTIONS}
onRowLimitChange={jest.fn()}
{...overrides}
/>,
{ useRedux: true },
);
test('shows the row count when the result fills the selected row limit', () => {
setup({ rowcount: 100, rowLimit: 100 });
expect(screen.getByTestId('row-count-label')).toHaveTextContent('100 rows');
});
test('warns that the row limit was reached when the result fills it', async () => {
setup({ rowcount: 100, rowLimit: 100 });
userEvent.hover(screen.getByTestId('row-count-label'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test('does not warn when the result is smaller than the selected row limit', async () => {
setup({ rowcount: 42, rowLimit: 100 });
expect(screen.getByTestId('row-count-label')).toHaveTextContent('42 rows');
userEvent.hover(screen.getByTestId('row-count-label'));
// Wait past antd's 0.1s mouseEnterDelay so a regression that made the
// tooltip appear would be caught here instead of racing the delay.
await act(() => sleep(150));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
test("warns when the chart's own row limit truncates below the selected row limit", async () => {
setup({ rowcount: 250, rowLimit: 1000, effectiveRowLimit: 250 });
userEvent.hover(screen.getByTestId('row-count-label'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test('labels the row limit selector so it is not read as a second row count', () => {
setup({ rowcount: 100, rowLimit: 100 });
expect(screen.getByText('Limit')).toBeInTheDocument();
});
@@ -16,12 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
screen,
render,
waitFor,
userEvent,
} from 'spec/helpers/testing-library';
import { screen, render, waitFor } from 'spec/helpers/testing-library';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { ResultsPaneOnDashboard } from '../components';
@@ -162,42 +157,6 @@ describe('useResultsPane query data reuse', () => {
expect(screen.queryByText('Sci-Fi')).not.toBeInTheDocument();
expect(screen.getByText('2 rows')).toBeVisible();
expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
userEvent.hover(screen.getByText('2 rows'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The row limit set for the chart was reached',
);
});
test("warns about this pane's own row limit, not the chart's, when the pane's selector is what caps the result", async () => {
// chart row_limit (2000) is well above this pane's default 1000-row
// selector, so the selector - not the chart - is what truncates here.
const props = createResultsPaneOnDashboardProps({
sliceId: 208,
rowLimit: 2000,
queriesResponse: [
{
colnames: ['genre'],
coltypes: [1],
data: Array.from({ length: 1500 }, (_, i) => ({
genre: `genre-${i}`,
})),
rowcount: 1500,
},
],
});
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
const rowCountLabel = await screen.findByTestId('row-count-label');
expect(rowCountLabel).toHaveTextContent('1k rows');
userEvent.hover(rowCountLabel);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent(
'The row limit selected for this pane was reached',
);
expect(tooltip).not.toHaveTextContent('for the chart');
});
test('renders an empty (0 rows) result from reused data without an API call', async () => {
@@ -84,12 +84,6 @@ export interface TableControlsProps extends DrillControlsProps {
canDownload: boolean;
rowLimit?: number;
rowLimitOptions?: { value: number; label: string }[];
// Effective result limit, capped by the chart's row limit.
// Defaults to `rowLimit` and controls the "row limit reached" warning.
effectiveRowLimit?: number;
// Overrides RowCountLabel's default "chart" wording for panes (e.g.
// samples) where the limit reached isn't the chart's own row_limit.
limitReachedMessage?: React.ReactNode;
onRowLimitChange?: (limit: number) => void;
}
@@ -110,9 +104,5 @@ export interface SingleQueryResultPaneProp
columnDisplayNames?: Record<string, string>;
rowLimit?: number;
rowLimitOptions?: { value: number; label: string }[];
effectiveRowLimit?: number;
// Overrides RowCountLabel's default "chart" wording when the pane's own
// row-limit selector, not the chart's row_limit, is what capped the result.
limitReachedMessage?: React.ReactNode;
onRowLimitChange?: (limit: number) => void;
}
@@ -16,14 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
type ReactNode,
ChangeEvent,
useMemo,
useState,
useCallback,
useEffect,
} from 'react';
import { ChangeEvent, useMemo, useState, useCallback, useEffect } from 'react';
import {
Input,
@@ -63,12 +56,6 @@ export type PropertiesModalProps = {
permissionsError?: string;
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
/** Optional render prop for injecting extra fields (e.g. folder selector). */
renderExtraFields?: (context: {
assetId: number;
assetType: 'chart';
accessorCount: number;
}) => { content: ReactNode; saveDisabled?: boolean; saveTooltip?: string };
};
function PropertiesModal({
@@ -78,7 +65,6 @@ function PropertiesModal({
show,
addSuccessToast,
addDangerToast,
renderExtraFields,
}: PropertiesModalProps) {
const [submitting, setSubmitting] = useState(false);
// values of form inputs
@@ -101,25 +87,6 @@ function PropertiesModal({
>(null);
const [tags, setTags] = useState<TagType[]>([]);
const chartId = slice.slice_id;
const extraFields = useMemo(
() =>
chartId
? renderExtraFields?.({
assetId: chartId,
assetType: 'chart',
accessorCount:
(selectedEditors?.length ?? 0) + (selectedViewers?.length ?? 0),
})
: undefined,
[
chartId,
renderExtraFields,
selectedEditors?.length,
selectedViewers?.length,
],
);
// Validation setup
const modalSections = useMemo(
() => [
@@ -314,20 +281,14 @@ function PropertiesModal({
title={t('Chart properties')}
isEditMode
saveDisabled={
submitting ||
!name ||
slice.is_managed_externally ||
hasErrors ||
extraFields?.saveDisabled
submitting || !name || slice.is_managed_externally || hasErrors
}
errorTooltip={
extraFields?.saveDisabled && extraFields?.saveTooltip
? extraFields.saveTooltip
: slice.is_managed_externally
? t(
"This chart is managed externally, and can't be edited in Superset",
)
: errorTooltip
slice.is_managed_externally
? t(
"This chart is managed externally, and can't be edited in Superset",
)
: errorTooltip
}
wrapProps={{ 'data-test': 'properties-edit-modal' }}
>
@@ -434,7 +395,6 @@ function PropertiesModal({
/>
</ModalFormField>
)}
{extraFields?.content}
</>
),
},
@@ -29,9 +29,7 @@ import { ControlFormItemComponents } from './ControlForm';
* Column formatting configs.
*/
export type ColumnConfig = {
[
key in SharedColumnConfigProp
]?: (typeof SHARED_COLUMN_CONFIG_PROPS)[key]['value'];
[key in SharedColumnConfigProp]?: (typeof SHARED_COLUMN_CONFIG_PROPS)[key]['value'];
} & Record<string, StrictJsonValue>;
/**
@@ -297,12 +297,6 @@ test('Click on Edit dataset', async () => {
const props = createProps();
fetchMock.removeRoute(getDbWithQuery);
fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery });
fetchMock.removeRoute(getDatasetWithAllMockRouteName);
fetchMock.get(
getDatasetWithAll,
{ result: {} },
{ name: getDatasetWithAllMockRouteName },
);
render(<DatasourceControl {...props} />, {
useRedux: true,
useRouter: true,
@@ -313,9 +307,7 @@ test('Click on Edit dataset', async () => {
await userEvent.click(screen.getByText('Edit dataset'));
});
expect(
await screen.findByTestId('mock-datasource-editor'),
).toBeInTheDocument();
expect(screen.getByTestId('mock-datasource-editor')).toBeInTheDocument();
});
test('Edit dataset should be disabled when user is not admin', async () => {
@@ -147,7 +147,6 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
onOpenPopover = noOp,
onClosePopover = noOp,
isOverflowingFilterBar = false,
hovered: isControlHovered = false,
} = props;
const defaultTimeFilter = useDefaultTimeFilter();
@@ -162,16 +161,9 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
const [evalResponse, setEvalResponse] = useState<string>(value);
const [tooltipTitle, setTooltipTitle] = useState<ReactNode | null>(t(value));
const [isDescriptionHovered, setIsDescriptionHovered] = useState(false);
const theme = useTheme();
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
useEffect(() => {
if (!isControlHovered) {
setIsDescriptionHovered(false);
}
}, [isControlHovered]);
useEffect(() => {
if (value === NO_TIME_RANGE) {
setActualTimeRange(NO_TIME_RANGE);
@@ -376,12 +368,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
}
overlayClassName="time-range-popover"
>
<Tooltip
placement="top"
title={isDescriptionHovered ? null : tooltipTitle}
mouseLeaveDelay={0}
overlayStyle={{ pointerEvents: 'none' }}
>
<Tooltip placement="top" title={tooltipTitle}>
{/* Wrap in a span so the Popover gets a stable DOM ref target;
DateLabel forwards its ref to an inner span used for measuring
text truncation, which would otherwise become the popover's
@@ -403,10 +390,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
return (
<>
<ControlHeader
{...props}
onDescriptionHoverChange={setIsDescriptionHovered}
/>
<ControlHeader {...props} />
{popoverContent}
</>
);

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