Compare commits

...

164 Commits

Author SHA1 Message Date
Nathan Voxland
04b7c5bf4d Fix CI failures: portable fetch query, force-flush buffer on stop!
- queue/appdb fetch!: rewrite the per-queue head selection as N small SELECT FOR
  UPDATE SKIP LOCKED queries inside one transaction, instead of a single LATERAL
  + VALUES query. Postgres, MySQL 8+, and H2 all support LATERAL but MariaDB does
  not, so the LATERAL version broke the MariaDB / MySQL test jobs. The per-queue
  loop is N+1 round trips per poll iteration, but N is bounded by the registered
  listener count (typically <10) and SKIP LOCKED still applies per row, so
  concurrent consumers still get different rows. Empty result correctly returns
  nil (was leaking [] in the new version).

- mq.init/stop! handle: always force-flush the (currently bound) publish buffer
  before checking executor ownership. Previously, in CI test mode where
  `metabase.test.initialize/mq` claimed ownership of the global flush executor
  first, the shutdown-drain tests' own stop! call ran with owns-buffer-flush?
  = false, so it skipped the flush — and their bound *publish-buffer* never
  drained to the DB. The fix runs `(flush-publish-buffer! true)` unconditionally,
  which drains whatever atom is currently bound. The executor shutdown remains
  ownership-gated.
2026-05-18 17:34:46 -05:00
Nathan Voxland
392a3dbe3d mq_perf: add RTT injection and concurrent pub+consume benchmark
- install-rtt! / uninstall-rtt! / with-rtt-ms: wrap the application DB's DataSource
  with a JDBC proxy that sleeps a randomized [min-ms max-ms] window before every
  execute*. Setting is a global atom (rtt-ms), not a dynamic var, so it applies to
  every thread — poll threads, worker pool, publisher threads alike — without
  needing per-thread binding conveyance.

- bench-concurrent-pub-consume!: publisher thread sustained at a target rate runs
  concurrently with the consumer. Each message carries publish-ns and the listener
  measures wall-clock latency. Exercises buffer-atom contention under writes and
  the per-channel active-handlers gate while publishing is still happening.

Findings from running the suite with injected RTT:
  - Publish throughput is roughly linear in RTT for batch=1 (922 -> 163 -> 82 msg/s
    at 0 / 1-2 / 5-10 ms RTT) but degrades much less at batch=100 (93k -> 17k -> 8k
    msg/s — still strong). Batch sizes >= 100 are the only viable setting for
    high-RTT environments.
  - Multi-queue scaling is essentially unaffected by RTT because the buffered
    publish path collapses most messages into a few rows. The DB write cost
    amortizes across hundreds of messages.
  - Bulk stale recovery scales gracefully under RTT (39ms -> 184ms for 500 rows at
    0 / 5-10 ms RTT). The per-row pre-fix version would have been 5-10s for the
    same workload at cloud RTT — confirming the bulk-update rewrite's value.
  - Concurrent pub+consume p50 latency is surprisingly insensitive to RTT
    (1045 ms baseline vs 1102 ms at 5-10 ms RTT). The dominant cost is the
    5-second consumer poll interval, not the DB round trip. To improve
    end-to-end latency on cloud DBs, lower the poll interval — not the RTT.
2026-05-18 16:35:27 -05:00
Nathan Voxland
7c34ef9b02 mq_perf: validate concurrency properly; fix bulk-recover return semantics
- bench-stale-recovery-race! now correctly reports rows ACTUALLY updated, not
  candidates seen. With 8 racing workers and 500 stale rows, sum-of-counts =
  500 (exactly-once recovery confirmed). Per-row 0.11ms — 12× faster than
  the pre-fix per-row loop on the same workload.

- recover-stale-processing-batches! returns the affected-row count from the
  bulk UPDATE, not the candidate-row count. Docstring clarifies that
  concurrent callers will see smaller numbers because the guard rejects rows
  already recovered by another node.

- bench-concurrent-consumers!: force *publish-buffer-ms* to 0 in the publish
  phase so each put becomes its own row (otherwise the time-window buffer
  collapses everything into one row that only one consumer can grab).
  Docstring updated to flag that this test validates exactly-once delivery
  and LATERAL fetch correctness, but does NOT measure cross-node throughput
  — `mq.impl/active-handlers` is a JVM-global atom so only one consumer can
  be processing per channel within a single JVM. Multi-JVM testing is still
  needed for real horizontal-scaling numbers.
2026-05-18 16:09:44 -05:00
Nathan Voxland
6eae581864 mq_perf: add concurrent-consumer and stale-recovery-race benchmarks; fix reflection warning
- New bench-concurrent-consumers!: spins up N AppDbQueueBackend instances in the
  same JVM, each with its own listener atom + poll thread, all sharing one queue.
  Validates that the LATERAL/SKIP LOCKED fetch distributes work across consumers
  and never double-processes a batch. Made possible by the per-instance backend
  refactor.
- New bench-stale-recovery-race!: pre-populates N stale processing rows and races
  W threads to recover them simultaneously. Confirms the guarded bulk UPDATE
  hands each row to exactly one winner.
- mq_perf: replace the removed mq/listen!/mq/batch-listen! calls with
  listener/batch-listen! (with explicit doseq when per-message handling is wanted).
- mq_perf: fix start-mq! to dispatch the correct ::MqStart key (was ::MqInit which
  never existed).
- mq.impl/submit-delivery!: pull the bound-fn into a let-bound ^Callable so the
  ExecutorService.submit dispatch resolves at compile time (eliminates the
  reflection warning).
2026-05-18 15:55:28 -05:00
Nathan Voxland
8ed89e869e Lint fixes 2026-05-18 14:45:02 -05:00
Nathan Voxland
549d278492 MQ QA fixes: backend per-instance state, async size-flush, bulk stale recovery, listener API cleanup
- AppDbQueueBackend / AppDbTopicBackend now carry owner-id, poll-state, offsets,
  and periodic-task rate-limit atoms as record fields. Production singleton uses
  make-backend; test fixture constructs its own. Test teardown no longer kills
  the production poll thread.
- mq.impl/start-worker-pool! and publish-buffer/start-publish-buffer-flush! return
  true if THIS call created the resource. mq.init handle records ownership so
  test stop! only shuts down what it owns; production globals survive test
  teardown.
- publish-buffer/buffered-publish! drops the synchronous flush when buffer hits
  max-batch-messages — just trips the channel's deadline and lets the 100ms
  scheduler do the work. Publisher threads never block on backend writes.
- recover-stale-processing-batches! does two bulk UPDATEs (retryable + permanent)
  instead of a per-row loop. Summary log per group plus per-queue analytics.
- topic.appdb publish! uses t2/insert! (was t2/insert-returning-pk! with the PK
  discarded).
- listener.clj: remove the single-message listen! function. def-listener! always
  routes to batch-listen!; listener body always receives a vec. batch-listen!
  no longer in mq.core public API (kept public in mq.listener for the macro to
  resolve). def-listener! docstring spells out why it's preferred over direct
  batch-listen! calls.
- Tests: replace mq/listen! calls with mq.tu/listen! (a test helper that wraps
  batch-listen! with per-message dispatch and error isolation). queue/core_test
  switches mq/batch-listen! → listener/batch-listen!.
2026-05-18 11:31:27 -05:00
Nathan Voxland
74770efa10 MQ refactors: ordered startup, generic start-receiving, buffer-side batch sizing
- startup/core: add startup-priority multimethod so def-startup-logic! methods
  can request ordering (default 500). MQ start now runs via def-startup-logic!
  with Long/MAX_VALUE instead of an explicit call from core.core.
- TopicBackend: split subscribe!/start! into start-receiving! (per-topic,
  idempotent, early) and start-handling! (per-backend, late) to model the
  two-phase lifecycle real brokers need. unsubscribe! kept as the destructive
  teardown.
- mq.init: rename capture-topic-offsets! to start-receiving!, expose via
  mq.core public API; core.core calls mq/start-receiving! generically.
- queue/appdb fetch!: rewrite the multi-queue pick as a LATERAL join with
  per-queue FOR UPDATE SKIP LOCKED so concurrent consumers actually share
  work instead of contending on the same head-of-queue.
- Move max-batch-messages partitioning out of queue/appdb publish! and into
  the publish pipeline. Backend publish! now just stores what it's given.
  buffered-publish! chunks on the sync path and flushes a channel when its
  buffered count hits max-batch-messages.
- listener: default max-batch-messages now applied in batch-listen! so both
  listen! and batch-listen! callers get the bound.
2026-05-18 04:53:27 -05:00
Nathan Voxland
0dc06ff1e3 Fix failing test 2026-05-13 10:43:23 -05:00
Nathan Voxland
9ee6dac54a Merge branch 'master' into persistent-queue-mq 2026-05-13 00:12:06 -05:00
Andrei Chernykh
68cd362ae6 Place unsectioned chart-settings widgets in the highest-priority section, not the first inserted (#73805)
* Place unsectioned chart-settings widgets in the highest-priority section, not the first inserted

* Address review: localize section priority, simplify defaults, refactor

* Add localized labels for all SectionIds and cover with i18n tests

* Simplify section priority by using localized labels directly
2026-05-12 21:12:41 -07:00
Nick Fitzpatrick
f084b2e557 Add context to a translation string to get proper meaning (#74093) 2026-05-13 09:00:01 +05:30
Nick Fitzpatrick
840127211b Increase min height for query builder header (#74068)
* Increase min height for query builder header

* adding assertion to e2e test
2026-05-13 08:59:36 +05:30
Nick Fitzpatrick
4e7692960f Fix color for selected option when moving dashboard param filters (#74067) 2026-05-13 08:59:19 +05:30
Nick Fitzpatrick
7fc65ddb6c do not show toggles on library collections in main app (#74027)
* don't show toggles on library collections in main app

* PR Feedback:
2026-05-13 08:59:01 +05:30
Nathan Voxland
8ff89b543c Removed core from mq api until it gets used somewhere 2026-05-12 19:08:48 -05:00
Nathan Voxland
2bb669708f Code review improvements 2026-05-12 17:57:10 -05:00
Tyler Plude
daa02967fa [BOT-1503] Fix cache read/write cols in ai usage log UA model (#74120)
fix ai usage log usage analytics model cache read/write columns
2026-05-12 22:51:00 +00:00
Nathan Voxland
524e6bf086 Switch MQ metrics to analytics-interface
Master moved Prometheus helper fns (inc!, set!/set-gauge!, observe!)
out of metabase.analytics.core and into metabase.analytics-interface.core
(see #72262), and the rename broke the namespace-decl check on this
branch. Point all metabase.mq.* files (and their tests) at the new
namespace, rename set! → set-gauge!, and add analytics-interface to
the mq module's :uses set.
2026-05-12 15:46:58 -05:00
Fraser
21aeddfa56 Move AddToDashSelectDashModal into common (#74051) 2026-05-13 08:29:18 +12:00
Nathan Voxland
ecd77df06b Moved persistent queue tables to v62 2026-05-12 15:29:01 -05:00
Nathan Voxland
5ad4b679e8 Merge remote-tracking branch 'origin/master' into persistent-queue-mq 2026-05-12 15:25:38 -05:00
Maksym Yakubych
7437cf3703 fix: move mcp and agent api to their own settings page (#73838) 2026-05-12 15:23:21 -05:00
Nathan Voxland
5b0b0ac624 Address PR review comments
- test-util: do-with-test-mq! now destructures :backend (not :kind),
  so parity tests actually exercise both backends.
- memory queue: persist failure count per channel across retries so
  queue-max-retries is reachable instead of looping forever.
- mq/impl.clj submit-delivery!: keep the swap! update fn side-effect
  free, use swap-vals! to detect ownership.
- publish-buffer: prepend retried (older) messages on flush failure
  so publish order survives transient errors.
- queue/appdb: stale-recovery update! now guards on status +
  status_heartbeat so concurrent recovery attempts only win once.
- topic/appdb: document that offsets advance on claim, not on
  listener completion.
- README: with-queue / with-topic examples now use the binding form.
- test init: drive test-mode MQ through mq.init/start! so the worker
  pool, publish-buffer flush, and memory polling loop actually run.
2026-05-12 15:17:48 -05:00
Kevin Kelleher
215ee8dc2b Fix timeline event tooltips for stacked series (#74036)
fix tooltips for stacked series
2026-05-12 16:16:45 -04:00
Maksym Yakubych
445c6359e3 fix: correctly handle invalid api key in settings (#73761) 2026-05-12 15:08:09 -05:00
Anderson
4e69f21aa3 Fix row chart data label formatting for split series (#74046) 2026-05-12 16:27:05 -03:00
dpsutton
ce13123024 turn off db routing for dependencies analysis (#74091)
BAD:
when we analyze dependencies of a card, we hit the db routing
middleware, there's no current user so it errors.

```
clojure.lang.ExceptionInfo: Error preprocessing query in #'metabase.query-processor.middleware.enterprise/attach-destination-db-middleware: Anonymous users cannot access a database with routing enabled.
{:fn #'metabase.query-processor.middleware.enterprise/attach-destination-db-middleware, :query {:lib/type :mbql/query, :lib/metadata (metabase.lib.metadata.invocation-tracker/invocation-tracker-provider (metabase.lib.metadata.cached-provider/cached-metadata-provider (metabase.lib-be.metadata.jvm/->UncachedApplicationDatabaseMetadataProvider 743))), :stages [{:lib/type :mbql.stage/native, :native "SELECT * FROM ORDERS"}], :database 743}, :type :qp}
 at metabase.query_processor.preprocess$fn__126698$preprocess126697__126699$fn__126700$fn__126701.invoke (preprocess.clj:149)
    clojure.lang.PersistentVector.reduce (PersistentVector.java:418)
    clojure.core$transduce.invokeStatic (core.clj:7025)
    clojure.core$transduce.invoke (core.clj:7012)
    metabase.query_processor.preprocess$fn__126698$preprocess126697__126699$fn__126700.invoke (preprocess.clj:123)
    metabase.query_processor.setup$fn__126674$do_with_qp_setup126673__126675.invoke (setup.clj:232)
    metabase.query_processor.setup$fn__126674$fn__126679.invoke (setup.clj:223)
    metabase.query_processor.preprocess$fn__126698$preprocess126697__126699.invoke (preprocess.clj:121)
    metabase.query_processor.preprocess$fn__126698$fn__126725.invoke (preprocess.clj:116)
    metabase.query_processor.compile$fn__126766$compile126765__126767$fn__126768.invoke (compile.clj:66)
    metabase.query_processor.setup$fn__126665$do_with_canceled_chan126664__126666$fn__126667.invoke (setup.clj:196)
    metabase.query_processor.setup$fn__126655$do_with_database_local_settings126654__126656$fn__126657.invoke (setup.clj:188)
    metabase.query_processor.setup$fn__126635$do_with_driver126634__126636$fn__126637$fn__126640.invoke (setup.clj:173)
    metabase.driver$do_with_driver.invokeStatic (driver.clj:54)
    metabase.driver$do_with_driver.invoke (driver.clj:49)
    metabase.query_processor.setup$fn__126635$do_with_driver126634__126636$fn__126637.invoke (setup.clj:172)
    metabase.query_processor.setup$fn__126622$do_with_metadata_provider126621__126623$fn__126624$fn__126625.invoke (setup.clj:141)
    metabase.query_processor.store$fn__105777$do_with_metadata_provider105776__105778.invoke (store.clj:200)
    metabase.query_processor.store$fn__105777$fn__105781.invoke (store.clj:186)
    metabase.query_processor.store$fn__105777$do_with_metadata_provider105776__105778.invoke (store.clj:193)
    metabase.query_processor.store$fn__105777$fn__105781.invoke (store.clj:186)

```

GOOD:
analyze the card's dependencies. DB routing uses identical shaped
databases so this is fine and desired.

It's also possible that this puts these tasks into weird error states
and then lots of them build up.
2026-05-12 13:56:03 -05:00
metamben
cb91c1125a Add environment variables for controlling the App DB connection pool (#74025)
Part of QUE2-570
2026-05-12 21:33:34 +03:00
metamben
d1a1c68e15 Report SQL errors with parameters the same as without parameters (#74041)
Closes QUE2-487
2026-05-12 21:32:00 +03:00
Romeo Van Snick
1da83bd51b Remove measure entity (#73756)
* Remove measure entity

* Clear cache between tests

* Subscribe to measures in QueryBuilder

* Hydrate measures from RTK

* Bring back measure in state.entities for now

* Parse measure definition

* Hydrate measures with API types

* Keep combining retired entities

* Fix types

* Only export entities from metabase/entities

* Use more meaningful types

* Keywordize measure keys
2026-05-12 19:09:53 +03:00
Riley Thompson
3728dbb5a5 Enable MBQL5 compilation for drivers (#71439)
* add sql-mbql5 driver for mbql5 query compilation

* fix e2e aggregation test

* fix kondo, update preprocess doc string

* extract logic into helper fn

* add test for metabase#52611

* replace maybe->pmbql hack with driver multi method dispatch on legacy mbql vs mbql5 lib methods

* fix kondo and field->field-filter-clause-test

* fix substitute-field-filter-test-2 and native-with-param-options-different-than-tag-type-test-2

* require binning feature for aggregation-function-inside-expression-in-aggregation-test

* remove pmbql

* fix aggregation-function-inside-expression-in-aggregation-test for athena with fixed bin width

* add more details in driver log

* fix bad merge

* update qp join test

* use test helper fn

* fix test helper

* H2 mbql5 experiment (#73347)

* Add h2-mbql5 as a separate driver. Run side-by-side experiment with h2.

This will allow us to compare the mbql5-native h2 driver with the
legacy-mbql h2 driver on stats without actively affecting the results.

* Review feedback; trim parents of h2-mbql5 and add comments around fixture.

* fix h2 dispatch and add namespaces to driver changelog methods

* custom report func

* use with-context for log

* match-one over match-lite

* another match-lite to match-one

* fix snowflake impersonation test

---------

Co-authored-by: technomancy <technomancy@gmail.com>
2026-05-12 15:51:18 +00:00
Timofey Kachalov
ba912fe646 feat(sdk,embed-js): Implement API for controllable parameters updates for dashboards and questions (#73475) 2026-05-12 19:30:44 +04:00
Nick Fitzpatrick
c3110721b7 localize last updated at value in Data Studio Table Metadata (#74001)
* localize last updated at value in Data Studio Table Metadata

* updating e2e
2026-05-12 19:38:43 +05:30
Tyler Plude
e851dc76ed [BOT-1496] fix metabot feedback migration logical filepath (#74053) 2026-05-12 15:44:17 +02:00
Romeo Van Snick
30033828b8 Remove Segment entity (#74064)
* Move segment consumers to RTK-based hooks

* Migrate RevisionHistoryApp to RTK-based hooks

* Remove segment entity

* Fix types

* Remove v1/Segment in lieu of api/Segment

* Only pass editable keys to update request

* Avoid casting
2026-05-12 15:35:38 +03:00
Chris Truter
a4a8bcc058 Run Eastwood against changed tests only (#73951) 2026-05-12 11:42:13 +00:00
Nathan Voxland
e8640a399a Added metabase.mq module readme 2026-04-20 16:05:23 -05:00
Nathan Voxland
e3cb29170e Lint fixes 2026-04-16 11:03:23 -05:00
Nathan Voxland
fea5506519 Lint fixes 2026-04-16 09:58:46 -05:00
Nathan Voxland
582ff189f7 Added ability to do double-delivery testing in with-test-mq 2026-04-16 08:51:58 -05:00
Nathan Voxland
358d7eec28 Added backend parity testing and removed sync backends 2026-04-15 14:20:36 -05:00
Nathan Voxland
dff14540d2 Use memory queue for testing, not sync queue 2026-04-15 11:51:56 -05:00
Nathan Voxland
8103ca8a48 Cleaning up mq code by switching to records from as much multimethods 2026-04-14 16:51:44 -05:00
Nathan Voxland
8282cee525 Merge remote-tracking branch 'origin/persistent-queue-mq' into persistent-queue-mq 2026-04-14 14:29:12 -05:00
Nathan Voxland
618831d368 Finished cleaning up "bundle" terminology to "batch" 2026-04-14 14:27:51 -05:00
Nathan Voxland
81e055c0cd Fix after-commit callbacks surviving nested transaction rollbacks; add publish buffer retry limit 2026-04-09 18:42:56 -05:00
Nathan Voxland
a5915a33b4 - Cleaned up now-unneeded analytics shim
- Cleaned up now-unneeded lazy requires
- Cleaned up start/stop logic
2026-04-07 15:06:38 -05:00
Nathan Voxland
fdea81a6bb Removed all changes that use the MQ code 2026-04-06 10:12:59 -05:00
Nathan Voxland
6fad3ef45c Merge remote-tracking branch 'origin/master' into persistent-queue-mq 2026-04-06 09:52:37 -05:00
Nathan Voxland
5ad741c0d2 Cleaning up code 2026-04-01 13:58:39 -05:00
Nathan Voxland
69570bc65f Cleaning up code 2026-03-31 20:15:55 -05:00
Nathan Voxland
b719615aa6 Cleaning up code 2026-03-31 17:09:51 -05:00
Nathan Voxland
afcc49f6f6 Refactor MQ test init: use :mq initialization step instead of is-test? checks
Replace scattered is-test? checks at var definition time with a proper
:mq initialization step in the test startup flow. This mirrors how
production initializes MQ via MqInit in startup/run-startup-logic!.

The :mq step:
- Loads driver.init (so def-listener! namespaces are loaded)
- Calls mq.init/init-for-tests! which sets sync backends, disables
  publish buffering, and registers all listeners

Also:
- Reverts var defaults to production values (appdb backends, 100ms buffer)
- Reverts def-listener! to lazy registration (no eager call)
- Restores with-sync-mq to use fresh *listeners* atom
- Removes all [TZ-DEBUG] diagnostic traces
- :db initialization depends on :mq

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:00:26 -05:00
Nathan Voxland
d12184f4a0 Fix eager listener registration: call multimethod, not method map lookup
The eager registration code used ((methods def-listener*) channel) which
returns the method fn from the methods map but doesn't invoke it. Changed
to (def-listener* channel) which dispatches through the multimethod and
actually calls the registration function.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:08:09 -05:00
Nathan Voxland
bde8a43aeb Add tracing to :db init to verify driver.init loading
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:27:56 -05:00
Nathan Voxland
76f8cb6ce3 Don't rebind *listeners* to empty atom in with-sync-mq
with-sync-mq was binding *listeners* to (atom {}), which created a
fresh empty atom that shadowed the root atom where listeners were
eagerly registered at def-listener! time. Since eftest propagates
bindings to worker threads, parallel tests running on threads spawned
within a with-sync-mq scope would see the empty atom and fail to find
any listeners.

Fix: stop rebinding *listeners*. Listeners are eagerly registered into
the root atom at namespace load time and should remain globally visible.
with-sync-mq still binds backends and buffer settings as needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:55:18 -05:00
Nathan Voxland
31840a1a31 Add atom identity tracing to diagnose listener registration
Trace which *listeners* atom instance is used during registration vs
lookup to detect if a dynamic rebinding is causing listeners to be
registered in one atom but looked up in another.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:35:43 -05:00
Nathan Voxland
a5968cdf24 Load driver/init.clj during test :db initialization
The MQ listeners defined in driver_notifications.clj (e.g.
connection-pool-invalidated) are only loaded via driver/init.clj, which
is loaded via core/init.clj in production. Tests don't load core/init.clj,
so these listener namespaces are never required and the eager registration
from def-listener! never happens.

Fix: require driver.init during test :db initialization so the listener
namespaces get loaded and listeners are registered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:37:56 -05:00
Nathan Voxland
5bcdea9b51 Eagerly register MQ listeners at def-listener! time
def-listener! now immediately registers the listener when the namespace
loads, instead of deferring to register-listeners! called from MqInit.
This ensures listeners are available in test mode where MqInit never runs
(tests use initialize-if-needed!, not startup/run-startup-logic!).

register-listener! is idempotent so the production path (MqInit calling
register-listeners!) still works fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:37:11 -05:00
Nathan Voxland
8eafca6c74 Add tracing to invoke-listener! to diagnose missing listener
The sync backend publish fires but listener output never appears.
Adding trace to see if listener is registered or not.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:57:50 -05:00
Nathan Voxland
6adf7f2ef8 Default MQ backends to sync in test mode
Same issue as *publish-buffer-ms*: MqInit never runs in tests, so the
alter-var-root that sets backends to sync has no effect. Set defaults
at var definition time based on config/is-test?.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:59:56 -05:00
Nathan Voxland
2ea7960d5c Default *publish-buffer-ms* to 0 in test mode
MqInit never runs in tests (tests use initialize-if-needed!, not
run-startup-logic!), so the alter-var-root in the previous fix had
no effect. Instead, set the default value at var definition time
based on config/is-test?.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 01:26:49 -05:00
Nathan Voxland
66af12b9cd Add more MQ tracing to diagnose CI failures
Traces at buffered-publish!, transport/publish!, sync-backend/publish!,
and MqInit to understand why *publish-buffer-ms* is still 100 in CI
(meaning messages are buffered instead of delivered immediately).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 01:02:26 -05:00
Nathan Voxland
8171f24196 Debugging CI failures 2026-03-31 00:27:49 -05:00
Nathan Voxland
a86dffa344 Fix MQ topic messages silently dropped in test mode
In test mode, MqInit sets sync backends but leaves *publish-buffer-ms*
at its default of 100ms. This causes buffered-publish! to buffer messages
instead of delivering them immediately. Since start-publish-buffer-flush!
is never called in test mode, no background thread drains the buffer,
so topic messages are silently dropped.

This broke report-timezone change notifications — connection pools were
never invalidated when timezone changed, causing stale timezone sessions
on database connections. This manifested as timezone-shifted query results
across 28+ driver tests in CI.

Fix: set *publish-buffer-ms* to 0 in test init path so messages publish
immediately without buffering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:56:59 -05:00
Nathan Voxland
db09e70823 Debugging CI failures 2026-03-30 20:27:04 -05:00
Nathan Voxland
407f3bc2df Merge branch 'master' into persistent-queue 2026-03-29 18:22:27 -05:00
Nathan Voxland
5b1181eba5 Fixed module setup 2026-03-28 01:40:24 -05:00
Nathan Voxland
1574a28b4f Fixed tests 2026-03-28 01:26:44 -05:00
Nathan Voxland
ace1e034a2 Fixed tests 2026-03-27 15:50:33 -05:00
Nathan Voxland
9142569d91 Fixed tests 2026-03-27 14:58:23 -05:00
Nathan Voxland
30c6275dc5 Fixed tests 2026-03-27 14:05:41 -05:00
Nathan Voxland
0f2e7ea906 Fixed tests 2026-03-27 13:51:36 -05:00
Nathan Voxland
cff57cbbb5 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	enterprise/backend/test/metabase_enterprise/agent_api/api_test.clj
2026-03-27 13:38:08 -05:00
Nathan Voxland
62c13bb028 Fixed formatting 2026-03-27 13:16:24 -05:00
Nathan Voxland
8f7064e87f Fixing tests 2026-03-27 12:56:26 -05:00
Nathan Voxland
27e5f298d8 Fixing tests 2026-03-27 09:27:36 -05:00
Nathan Voxland
c6dff735a9 Fixing tests 2026-03-26 18:13:35 -05:00
Nathan Voxland
daaca9ef62 CI fixes 2026-03-26 12:30:15 -05:00
Nathan Voxland
ad9b980fef Merge remote-tracking branch 'origin/master' into persistent-queue 2026-03-26 11:12:10 -05:00
Nathan Voxland
96a308a64e CI fixes 2026-03-26 11:11:47 -05:00
Nathan Voxland
3c24edbf47 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	src/metabase/settings/models/setting.clj
2026-03-26 09:33:55 -05:00
Nathan Voxland
93b375bbe2 Merged from master 2026-03-26 01:33:25 -05:00
Nathan Voxland
ca3a2ea136 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	src/metabase/search/core.clj
#	src/metabase/search/ingestion.clj
#	src/metabase/search/task/search_index.clj
#	src/metabase/testing_api/api.clj
2026-03-25 23:49:31 -05:00
Nathan Voxland
e9f6c93013 Improved performance benchmark and added back skip lock 2026-03-25 21:17:35 -05:00
Nathan Voxland
89786605c1 Fixing tests and created a mq-perf script 2026-03-25 15:19:29 -05:00
Nathan Voxland
82f5dea54d Code cleanup 2026-03-25 02:28:42 -05:00
Nathan Voxland
ad62aed71a Cleaning up code 2026-03-24 02:15:25 -05:00
Nathan Voxland
3ab76adf8d Merged from master 2026-03-24 01:36:35 -05:00
Nathan Voxland
1f019c1e10 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	enterprise/backend/src/metabase_enterprise/action_v2/data_editing.clj
#	enterprise/backend/test/metabase_enterprise/action_v2/api_test.clj
#	enterprise/backend/test/metabase_enterprise/agent_api/api_test.clj
#	enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj
#	enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj
#	src/metabase/analytics/prometheus.clj
2026-03-24 01:28:52 -05:00
Nathan Voxland
3e4335a798 Merge remote-tracking branch 'origin/persistent-queue' into persistent-queue 2026-03-24 01:19:44 -05:00
Nathan Voxland
70967125aa Code cleanup 2026-03-24 01:16:36 -05:00
Nathan Voxland
5921542894 Code cleanup 2026-03-24 01:05:12 -05:00
Nathan Voxland
8c572f77f4 Fix race conditions in submit-delivery! and topic offset advancement
- submit-delivery!: atomic busy-check and registration via single swap!
  to prevent concurrent handlers for the same channel
- start-worker-pool!: use compare-and-set! to prevent pool leak on
  concurrent initialization
- Topic backends: only advance offset when submit-delivery! returns true,
  preventing silent message loss when channel is busy
2026-03-23 19:10:23 -05:00
Nathan Voxland
7e5bb21c3d Fix high-priority issues: retry flush failures, non-blocking batch processing, atomic after-commit drain
- Publish buffer: re-buffer messages on flush failure instead of dropping them, with Prometheus counter
- Queue/topic processing: centralized non-blocking dispatch via shared worker pool in mq.impl, with busy-channel tracking so poll threads never block on listener execution
- Queue heartbeats: poll thread updates status_heartbeat every 2min for in-flight batches to prevent stale recovery during long processing
- After-commit callbacks: atomically drain with reset-vals! loop to support callbacks that register new callbacks
- Document all-or-nothing ACK semantics on queue bundle retry in handle! docstring
2026-03-23 18:58:14 -05:00
Nathan Voxland
2c8c0e30aa Better consolidation of topic vs queue logic 2026-03-23 14:44:03 -05:00
Nathan Voxland
4ca03e17ef Took out now-unneeded search delay 2026-03-18 02:05:25 -05:00
Nathan Voxland
ec2b0f38ae Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	src/metabase/analytics/prometheus.clj
2026-03-18 01:46:46 -05:00
Nathan Voxland
2bad85564e Publish messages after an appdb transaction. Plus batch them still for 100ms to handle times we don't really have a transaction to avoid so many small batches 2026-03-18 01:45:26 -05:00
Nathan Voxland
d1252c7e8e Fixed merges from master 2026-03-13 11:59:15 -05:00
Nathan Voxland
4a7059cac7 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	resources/migrations/059_update_migrations.yaml
#	src/metabase/search/appdb/core.clj
#	src/metabase/search/core.clj
#	src/metabase/search/impl.clj
#	src/metabase/search/ingestion.clj
#	src/metabase/search/task/search_index.clj
2026-03-13 11:09:28 -05:00
Nathan Voxland
9bd22bf4b1 Added dedup-fn feature 2026-03-10 02:03:18 -05:00
Nathan Voxland
0462cde56d Fixed kondo 2026-03-09 11:14:43 -05:00
Nathan Voxland
e99312fe64 Renamed def-listener to def-listener! 2026-03-09 10:24:36 -05:00
Nathan Voxland
d8a7cd3501 Fixing tests 2026-03-08 01:41:38 -06:00
Nathan Voxland
2d2c4b35a5 Fixing tests 2026-03-07 01:25:05 -06:00
Nathan Voxland
9e717a6056 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	resources/migrations/059_update_migrations.yaml
2026-03-07 00:09:19 -06:00
Nathan Voxland
9ed603e6ce Better multi-backend handling 2026-03-06 17:30:39 -06:00
Nathan Voxland
8a5545ad32 Fixed whitespace and migrations 2026-03-06 16:01:30 -06:00
Nathan Voxland
82b89e31fb Fixing tests 2026-03-06 15:09:41 -06:00
Nathan Voxland
607985e054 Fixing tests 2026-03-05 22:15:42 -06:00
Nathan Voxland
a2e43e1996 Fixing tests 2026-03-05 21:23:23 -06:00
Nathan Voxland
3b96cd4619 Deps fix 2026-03-05 15:56:10 -06:00
Nathan Voxland
31790b6052 Added def-listener! and fixing tests 2026-03-05 15:42:23 -06:00
Nathan Voxland
846f38d443 Fixing tests 2026-03-05 02:00:51 -06:00
Nathan Voxland
e7a9e8c100 Removed other settings cache sync code 2026-03-05 01:20:07 -06:00
Nathan Voxland
e1b7b8e912 Use new queue better in search 2026-03-05 00:46:36 -06:00
Nathan Voxland
2480ce058b Support exclusive queue listeners 2026-03-04 11:27:19 -06:00
Nathan Voxland
cf04ba48c2 Immediately deliver topic posts locally without waiting for backend roundtrip 2026-03-04 09:51:00 -06:00
Nathan Voxland
8a853116bf Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	.clj-kondo/config.edn
2026-03-04 01:26:56 -06:00
Nathan Voxland
c54c9ded12 improving code 2026-03-04 01:20:12 -06:00
Nathan Voxland
0fc1a4b8c9 Code improvements and removed postgres topic listener 2026-03-04 00:18:19 -06:00
Nathan Voxland
6ca5625902 Code improvements 2026-02-27 12:47:50 -05:00
Nathan Voxland
a4fdcdef64 Code review fixes 2026-02-21 10:21:20 -06:00
Nathan Voxland
b1fe71ca2f Cleaning up force-sync usage 2026-02-21 02:29:02 -06:00
Nathan Voxland
4daf2cb8a2 Added batch-listen! 2026-02-21 01:52:57 -06:00
Nathan Voxland
4a3d739318 Started to migrate old util.queue code to new mq 2026-02-20 15:41:51 -06:00
Nathan Voxland
ba85c0513c Cleanup failed queue messages 2026-02-19 16:55:00 -06:00
Nathan Voxland
b8c26e3c0c Code review fixes 2026-02-19 16:48:09 -06:00
Nathan Voxland
21b5b3e93a Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	resources/migrations/059_update_migrations.yaml
2026-02-19 16:20:28 -06:00
Nathan Voxland
0a1a13b48b Code cleanup 2026-02-19 16:09:11 -06:00
Nathan Voxland
a18f7a94e2 Code cleanup 2026-02-19 15:34:05 -06:00
Nathan Voxland
067626c71d Added metrics 2026-02-19 10:41:42 -06:00
Nathan Voxland
3bc10ee2e1 Cleanly shut down 2026-02-19 09:53:39 -06:00
Nathan Voxland
b9fba6ab7a Cleaning up topic code 2026-02-19 01:15:32 -06:00
Nathan Voxland
20e5611810 Cleaning up topic code 2026-02-18 16:26:15 -06:00
Nathan Voxland
37d68bca75 Made mq.topic code look like mq.queue code 2026-02-18 15:52:48 -06:00
Nathan Voxland
e58e0b7f80 Queue code improvements 2026-02-18 15:21:22 -06:00
Nathan Voxland
81aab7b8ca Added schemas 2026-02-18 14:15:45 -06:00
Nathan Voxland
bcf8a422f6 Queue code cleanup 2026-02-18 14:03:30 -06:00
Nathan Voxland
ac434a50aa Queue code cleanup 2026-02-18 13:02:49 -06:00
Nathan Voxland
4f050fba0a Use notify on postgres 2026-02-18 01:25:29 -06:00
Nathan Voxland
c36a72146e moved queue and pubsub to .mq 2026-02-18 00:27:53 -06:00
Nathan Voxland
d82ec362ba Added pub/sub messaging 2026-02-17 17:04:11 -06:00
Nathan Voxland
c2172311f5 Better queue testing with memory queue 2026-02-17 16:19:34 -06:00
Nathan Voxland
5a7bcfab93 Better naming for storing messages in the appdb queue 2026-02-17 15:37:43 -06:00
Nathan Voxland
decbc82b1a Code review fixes 2026-02-17 14:56:27 -06:00
Nathan Voxland
bd0f5b3b69 Merge remote-tracking branch 'origin/master' into persistent-queue 2026-02-17 14:33:54 -06:00
Nathan Voxland
7f98271a59 Don't have a queue_message model since it's an implementationd detail 2026-01-26 17:04:26 -06:00
Nathan Voxland
8cc219994d Merge remote-tracking branch 'origin/master' into persistent-queue 2026-01-26 16:44:09 -06:00
Nathan Voxland
84168dbf05 Merge remote-tracking branch 'origin/master' into persistent-queue
# Conflicts:
#	resources/migrations/059_update_migrations.yaml
2026-01-26 15:27:21 -06:00
Nathan Voxland
d6da219a70 Fixes from code review and getting back to project 2026-01-16 16:28:37 -06:00
Nathan Voxland
65426fe653 Merge remote-tracking branch 'refs/remotes/origin/master' into persistent-queue
# Conflicts:
#	resources/migrations/059_update_migrations.yaml
2026-01-16 16:11:19 -06:00
Nathan Voxland
6403d80fea Merge branch 'master' into persistent-queue 2026-01-14 11:11:53 -06:00
Nathan Voxland
bbcba1500c Added queue_message table back 2026-01-12 14:29:48 -06:00
Nathan Voxland
fcf4dbb6e8 Merge branch 'master' into persistent-queue
# Conflicts:
#	.clj-kondo/config/modules/config.edn
#	resources/migrations/056_update_migrations.yaml
2026-01-12 14:29:04 -06:00
Nathan Voxland
3d75754ad8 Added sucess and failure handling, general cleanup 2025-07-18 17:06:05 -05:00
Nathan Voxland
af72309d8e e2e test works again 2025-07-17 22:30:10 -05:00
Nathan Voxland
646b4c4548 Reverted metabase.queue changes, implemented metabase.queue.memory, code cleanup 2025-07-15 21:00:16 -05:00
Nathan Voxland
92ada9186c Code cleanup 2025-07-15 14:33:44 -05:00
Nathan Voxland
e67ce4582e Moved migrations 2025-07-14 23:07:20 -05:00
Nathan Voxland
25e3afcdbb Refactoring and trying to get e2e test to pass again 2025-07-14 23:07:20 -05:00
Nathan Voxland
b31a9a09c5 Configured queue listener to use persistent queue 2025-07-14 23:07:20 -05:00
Nathan Voxland
50614c15aa Initial implementation 2025-07-14 23:07:20 -05:00
202 changed files with 11720 additions and 1202 deletions

View File

@@ -312,8 +312,7 @@
metabase-enterprise.transforms.interface/execute! {:message "Use metabase-enterprise.transforms.execute/execute! instead of metabase-enterprise.transforms.interface/execute!"}
next.jdbc/active-tx? {:message "Use metabase.app-db.core/in-transaction? to see if we are in a transaction"}
toucan2.core/debug {:message "Don't commit code using t2/debug"}
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}
}
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}}
:discouraged-namespace
{camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
@@ -593,6 +592,10 @@
metabase.model-persistence.api-test/with-persistence-setup! hooks.common/with-one-top-level-binding
metabase.model-persistence.api-test/with-setup! hooks.common/with-one-top-level-binding
metabase.model-persistence.test-util/with-persistence-enabled! hooks.metabase.model-persistence.test-util/with-persistence-enabled!
metabase.mq.core/with-queue hooks.common/let-second
metabase.mq.core/with-topic hooks.common/let-second
metabase.mq.test-util/with-test-mq hooks.common/with-one-binding
metabase.mq.core/def-listener! hooks.metabase.mq.impl/def-listener
metabase.models.interface/define-batched-hydration-method hooks.metabase.models.interface/define-hydration-method
metabase.models.interface/define-simple-hydration-method hooks.metabase.models.interface/define-hydration-method
metabase.permissions.core/allow-changing-all-users-group-members hooks.common/do*

View File

@@ -1061,6 +1061,7 @@
metabase.lib.init
metabase.lib.metadata
metabase.lib.metadata.protocols
metabase.lib.options
metabase.lib.schema
metabase.lib.schema.actions
metabase.lib.schema.aggregation
@@ -1353,6 +1354,16 @@
:model/Table
:model/User}}
mq
{:team "UX West"
:api #{metabase.mq.core metabase.mq.init}
:uses #{analytics-interface
app-db
models
settings
startup
util}}
native-query-snippets
{:team "Graphy"
:api #{metabase.native-query-snippets.api
@@ -1757,6 +1768,7 @@
metabase.query-processor.streaming.interface
metabase.query-processor.timezone
metabase.query-processor.util
metabase.query-processor.util.add-alias-info
metabase.query-processor.util.persisted-cache}
:uses #{analytics
analytics-interface
@@ -2952,6 +2964,7 @@
api
app-db
collections
database-routing
documents
driver
events

View File

@@ -0,0 +1,24 @@
(ns hooks.metabase.mq.impl
(:require
[clj-kondo.hooks-api :as hooks]))
(defn def-listener
"Kondo hook for `mq/def-listener`. Rewrites to a `fn` form so kondo can analyze bindings.
Forms:
(mq/def-listener! :topic/foo [msg] body...)
(mq/def-listener! :queue/bar {:config map} [msgs] body...)"
[{:keys [node]}]
(let [[_ _channel-name & args] (:children node)
;; skip optional config map
args (if (hooks/map-node? (first args))
(rest args)
args)
[bindings & body] args
;; rewrite as: (fn [bindings...] body...)
new-node (hooks/list-node
(list*
(hooks/token-node 'fn)
bindings
body))]
{:node new-node}))

View File

@@ -37,7 +37,8 @@ jobs:
name: Run Eastwood linter
be-linter-eastwood-test:
if: ${{ !inputs.skip && github.ref_name == 'master' }}
# Master / release safety net: lint every backend test namespace.
if: ${{ !inputs.skip && github.event_name == 'push' && (github.ref_name == 'master' || startsWith(github.ref_name, 'release-')) }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
name: Eastwood (tests only)
@@ -48,6 +49,64 @@ jobs:
- run: clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood/test
name: Run Eastwood linter on our backend tests
be-linter-eastwood-test-changed:
# PR / merge queue: lint only the test namespaces touched by the diff.
if: ${{ !inputs.skip && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 15
name: Eastwood (changed tests only)
steps:
- uses: actions/checkout@v4
with:
# Enough history for `git merge-base` on a typical PR.
fetch-depth: 200
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Compute changed test namespaces
id: changed
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }}
run: |
set -euo pipefail
# Diff from the merge-base so changes that landed on the base branch after the PR was
# last synced don't widen our scope. Falls back to BASE_SHA if older than fetched depth.
git fetch --no-tags --depth=200 origin "$BASE_SHA"
DIFF_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA" 2>/dev/null || echo "$BASE_SHA")
# Test roots we lint: core, EE, and each driver module's test tree.
# :eastwood/test derives matching :source-paths from the classpath, so any
# namespace we name here is reachable. Include renames (R) so a test that
# was just renamed still gets linted under its new name.
FILES=$(git diff --name-only --diff-filter=AMR "$DIFF_BASE" "$HEAD_SHA" \
-- 'test/**/*.clj' 'test/**/*.cljc' \
'enterprise/backend/test/**/*.clj' 'enterprise/backend/test/**/*.cljc' \
'modules/drivers/**/test/**/*.clj' 'modules/drivers/**/test/**/*.cljc')
if [ -z "$FILES" ]; then
echo "No backend test files changed; skipping lint."
echo "namespaces=" >> "$GITHUB_OUTPUT"
exit 0
fi
# Defence-in-depth: paths flow into a subsequent shell command via $GITHUB_OUTPUT.
if grep -Eqv '^[A-Za-z0-9_./-]+$' <<<"$FILES"; then
echo "Refusing to lint: unexpected characters in changed file paths:" >&2
echo "$FILES" >&2
exit 1
fi
NS=$(sed -E 's#^(test/|enterprise/backend/test/|modules/drivers/[^/]+/test/)##; s#\.cljc?$##; s#/#.#g; s#_#-#g' <<<"$FILES" \
| sort -u \
| paste -sd' ' -)
echo "Changed test namespaces: $NS"
echo "namespaces=$NS" >> "$GITHUB_OUTPUT"
- name: Run Eastwood on changed test namespaces
if: steps.changed.outputs.namespaces != ''
env:
NAMESPACES: ${{ steps.changed.outputs.namespaces }}
run: clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood/test :namespaces "[$NAMESPACES]"
be-tests:
if: ${{ !inputs.skip }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
@@ -196,6 +255,7 @@ jobs:
needs:
- be-linter-eastwood
- be-linter-eastwood-test
- be-linter-eastwood-test-changed
- be-linter-clj-kondo
- be-cljfmt
- be-check

View File

@@ -664,10 +664,8 @@
;;
;; clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood/test
:eastwood/test
{:exec-fn metabase.linters.eastwood/eastwood
:exec-args {:source-paths ["test"
"enterprise/backend/test"]
:linters [:reflection]
{:exec-fn metabase.linters.eastwood/eastwood-tests
:exec-args {:linters [:reflection]
:exclude-namespaces
[;; byte/1 dispatch value trips up Eastwood's reader
metabase.test.http-client

870
dev/src/dev/mq_perf.clj Normal file
View File

@@ -0,0 +1,870 @@
(ns dev.mq-perf
"Performance benchmarks for the appdb-backed MQ system (queues and topics).
Measures publishing throughput, end-to-end latency, and sustained throughput
under different load levels. Benchmarks exercise the full realistic pipeline:
publish buffer → DB → poll thread → worker pool → listener.
Self-contained — starts the MQ subsystem without the full Metabase server.
Supports multi-node testing: run multiple JVM instances against the same DB.
Example usage:
;; Single-node: start MQ and run all benchmarks
(start-mq!)
(run-all-benchmarks!)
;; Multi-node coordination: start multiple JVMs, each calls:
(start-mq!)
(run-all-benchmarks-coordinated!)
;; Then from any node (or a separate REPL), publish the go signal:
(signal-go!)
;; Individual benchmarks
(bench-publish-throughput! {:n 1000 :batch-sizes [1 10 100]})
(bench-e2e-latency! {:n 50 :rate :medium :transport :queue})
(bench-sustained-throughput! {:duration-sec 15 :rate :medium :transport :queue})
(bench-batch-listener! {:n 200 :batch-sizes [1 10 50]})
(bench-multi-queue! {:queue-counts [1 5 10 20] :msgs-per-queue 50})"
(:require
[clojure.string :as str]
[metabase.app-db.connection :as mdb.conn]
[metabase.mq.core :as mq]
[metabase.mq.listener :as listener]
[metabase.mq.polling :as polling]
[metabase.mq.publish-buffer :as publish-buffer]
[metabase.mq.queue.appdb :as q.appdb]
[metabase.mq.queue.backend :as q.backend]
[metabase.mq.topic.appdb :as topic.appdb]
[metabase.mq.topic.backend :as topic.backend]
[toucan2.core :as t2])
(:import
(java.lang.reflect InvocationHandler Method Proxy)
(java.sql Connection PreparedStatement Statement)
(java.util.concurrent CountDownLatch TimeUnit)
(javax.sql DataSource)))
(set! *warn-on-reflection* true)
;;; ------------------------------------------------ RTT injection ------------------------------------------------
;;;
;;; Wraps the application DB's `DataSource` so every JDBC `execute*` call sleeps a randomized
;;; interval before hitting Postgres. This lets us see what the perf picture looks like with
;;; cloud-realistic latency (~1-5 ms RTT) without leaving the JVM. The wrapper preserves the
;;; pooled DataSource underneath, so connection acquisition is unaffected — only query
;;; round-trips pay the simulated cost.
(defonce ^:private rtt-ms
;; Use an atom rather than a dynamic var so the injected RTT applies to *every* thread —
;; including the backend poll threads and the worker pool — without needing per-thread
;; bindings to be conveyed.
(atom nil))
(defn- sleep-rtt! []
(when-let [[mn mx] @rtt-ms]
(let [delay (long (if (= mn mx) mn (+ mn (rand-int (inc (- mx mn))))))]
(when (pos? delay) (Thread/sleep delay)))))
(defn- proxy-statement
"Wrap a Statement / PreparedStatement so every `execute*` call sleeps the injected RTT
before delegating. Returns a proxy that implements both `Statement` and
`PreparedStatement` (PreparedStatement extends Statement, so a single proxy covers both)."
[^Statement stmt]
(Proxy/newProxyInstance
(.getClassLoader Statement)
(into-array Class [Statement PreparedStatement])
(reify InvocationHandler
(invoke [_ _proxy method args]
(when (.startsWith (.getName ^Method method) "execute")
(sleep-rtt!))
(.invoke method stmt args)))))
(defn- proxy-connection
"Wrap a Connection so prepareStatement / createStatement return latency-injecting Statements."
[^Connection conn]
(Proxy/newProxyInstance
(.getClassLoader Connection)
(into-array Class [Connection])
(reify InvocationHandler
(invoke [_ _proxy method args]
(let [n (.getName ^Method method)
result (.invoke method conn args)]
(if (and (or (= n "prepareStatement") (= n "createStatement"))
(instance? Statement result))
(proxy-statement result)
result))))))
(defn- latent-datasource
"Returns a wrapped DataSource whose connections inject RTT before every `execute*`.
Delegates to the underlying pooled DataSource for connection acquisition itself."
^DataSource [^DataSource inner]
(reify DataSource
(getConnection [_] (proxy-connection (.getConnection inner)))
(getConnection [_ user pw] (proxy-connection (.getConnection inner user pw)))
(getLoginTimeout [_] (.getLoginTimeout inner))
(setLoginTimeout [_ s] (.setLoginTimeout inner s))
(getLogWriter [_] (.getLogWriter inner))
(setLogWriter [_ pw] (.setLogWriter inner pw))
(getParentLogger [_] (.getParentLogger inner))
(^boolean isWrapperFor [_ ^Class iface] (.isWrapperFor inner iface))
(^Object unwrap [_ ^Class iface] (.unwrap inner iface))))
(defonce ^:private wrapped-datasource? (atom false))
(defn install-rtt!
"Wraps the app DB's DataSource so every JDBC `execute*` call sleeps `[min-ms max-ms]`
before delegating. Idempotent — calling this twice keeps a single wrapper. Use
`(install-rtt! nil nil)` (or `(uninstall-rtt!)`) to disable injection without unwrapping.
Returns the new effective `[min max]`."
[min-ms max-ms]
(reset! rtt-ms (when (and min-ms max-ms) [min-ms max-ms]))
(when-not @wrapped-datasource?
(let [orig mdb.conn/*application-db*
wrapped (mdb.conn/application-db (.db-type orig)
(latent-datasource (.data-source orig))
:create-pool? false)]
(alter-var-root #'mdb.conn/*application-db* (constantly wrapped))
(reset! wrapped-datasource? true)))
@rtt-ms)
(defn uninstall-rtt!
"Disables RTT injection. Leaves the DataSource wrapper in place (no-op when rtt-ms is nil)
to avoid surprises if other code captured the wrapped instance."
[]
(reset! rtt-ms nil))
(defmacro with-rtt-ms
"Runs `body` with `[min-ms max-ms]` RTT injected on every JDBC execute, then restores the
prior setting."
[[min-ms max-ms] & body]
`(let [prev# @rtt-ms]
(install-rtt! ~min-ms ~max-ms)
(try ~@body
(finally
(reset! rtt-ms prev#)))))
;;; ------------------------------------------------ Startup ------------------------------------------------
(defonce ^:private started? (atom false))
(defn start-mq!
"Minimal startup: connects to app DB and starts the MQ subsystem.
No Jetty, no scheduler, no sample data. Idempotent."
[]
(let [setup-db! (requiring-resolve 'metabase.app-db.core/setup-db!)]
(setup-db! :create-sample-content? false))
(let [setup-prometheus! (requiring-resolve 'metabase.analytics.prometheus/setup!)]
(setup-prometheus!))
(require 'metabase.mq.init)
(let [startup! (requiring-resolve 'metabase.startup.core/def-startup-logic!)]
(startup! :metabase.mq.init/MqStart))
(reset! started? true)
(println "MQ system started."))
(defn- ensure-started!
"Starts the MQ system if not yet started, and restarts any dead poll threads."
[]
(when-not @started?
(start-mq!))
;; Restart poll threads if they died (e.g., from InterruptedException during a previous benchmark).
;; start! is idempotent and start-polling! detects dead futures.
(let [start-transports! (requiring-resolve 'metabase.mq.impl/start-transports)]
(start-transports!)))
;;; ------------------------------------------------ Utilities ------------------------------------------------
(def ^:private shared-queues
"Fixed set of 10 shared queue names used across all nodes for cross-node contention testing."
(mapv #(keyword "queue" (str "bench-shared-" %)) (range 10)))
(def ^:private shared-topic :topic/bench-shared)
(defn- run-id
"Generates an 8-character unique identifier for a benchmark run."
[]
(subs (str (random-uuid)) 0 8))
(defn- make-channel
"Creates a unique channel keyword for benchmarking."
[transport id suffix]
(keyword (name transport) (str "bench-" id "-" suffix)))
(defn- random-shared-queue
"Returns a random shared queue name."
[]
(nth shared-queues (rand-int (count shared-queues))))
(defn- percentile
"Returns the p-th percentile from a sorted vector (p in [0,1])."
[sorted-vec p]
(if (empty? sorted-vec)
0.0
(let [idx (min (dec (count sorted-vec))
(int (* p (count sorted-vec))))]
(double (nth sorted-vec idx)))))
(defn- collect-stats
"Computes summary statistics from a collection of numeric values.
Returns {:count :min :max :avg :p50 :p95 :p99}."
[values]
(if (empty? values)
{:count 0 :min 0.0 :max 0.0 :avg 0.0 :p50 0.0 :p95 0.0 :p99 0.0}
(let [sorted (vec (sort values))
n (count sorted)]
{:count n
:min (double (first sorted))
:max (double (last sorted))
:avg (/ (reduce + 0.0 sorted) n)
:p50 (percentile sorted 0.50)
:p95 (percentile sorted 0.95)
:p99 (percentile sorted 0.99)})))
(defn- fmt
"Formats a number for display."
[x]
(if (integer? x)
(str x)
(format "%.2f" (double x))))
(defn- print-table!
"Prints a formatted ASCII table."
[title headers rows]
(let [col-widths (mapv (fn [i]
(apply max
(count (nth headers i))
(map #(count (str (nth % i))) rows)))
(range (count headers)))
separator (str "+-" (str/join "-+-" (map #(apply str (repeat % \-)) col-widths)) "-+")
fmt-row (fn [vals]
(str "| "
(str/join " | "
(map-indexed (fn [i v]
(format (str "%-" (nth col-widths i) "s") (str v)))
vals))
" |"))]
(println)
(println title)
(println separator)
(println (fmt-row headers))
(println separator)
(doseq [row rows]
(println (fmt-row row)))
(println separator)
(println)))
(defn- cleanup!
"Removes all benchmark data for the given run-id."
[id]
(t2/delete! :queue_message_batch :queue_name [:like (str "bench-" id "%")])
(t2/delete! :topic_message_batch :topic_name [:like (str "bench-" id "%")])
(doseq [[ch _] @listener/*listeners*]
(when (str/includes? (name ch) (str "bench-" id))
(mq/unlisten! ch))))
(defn- publish-at-rate!
"Publishes `n` messages at the given rate (msgs/sec). `publish-fn` is called with each seq num.
If rate is nil, publishes as fast as possible (burst mode)."
[n rate publish-fn]
(if (nil? rate)
(dotimes [i n]
(publish-fn i))
(let [interval-ms (long (/ 1000 rate))]
(dotimes [i n]
(let [start (System/currentTimeMillis)]
(publish-fn i)
(let [elapsed (- (System/currentTimeMillis) start)
sleep-ms (- interval-ms elapsed)]
(when (pos? sleep-ms)
(Thread/sleep (long sleep-ms)))))))))
(def ^:private rate-map
{:low 10
:medium 100
:high 1000
:burst nil})
(defn- rate->msgs-sec [rate]
(get rate-map rate rate))
;;; ---------------------------------------- Benchmark 1: Publishing Throughput ----------------------------------------
(defn bench-publish-throughput!
"Measures raw publishing speed for different batch sizes.
This benchmark bypasses the publish buffer to measure direct DB insert speed.
Options:
:n - total messages to publish per batch-size test (default 1000)
:batch-sizes - vector of batch sizes to test (default [1 10 100])
:transport - :queue or :topic (default :queue)"
([] (bench-publish-throughput! {}))
([{:keys [n batch-sizes transport]
:or {n 1000 batch-sizes [1 10 100] transport :queue}}]
(ensure-started!)
(let [id (run-id)
results (atom [])]
(try
(doseq [bs batch-sizes]
(let [channel (make-channel transport id (str "pub-" bs))
iterations (/ n bs)
start (System/nanoTime)]
(binding [publish-buffer/*publish-buffer-ms* 0]
(dotimes [i iterations]
(let [msgs (vec (for [j (range bs)]
{:seq (+ (* i bs) j) :ts (System/currentTimeMillis)}))]
(if (= transport :queue)
(q.backend/publish! q.appdb/backend channel msgs)
(topic.backend/publish! topic.appdb/backend channel msgs)))))
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)]
(swap! results conj
[(str bs) (fmt (/ n (/ elapsed-ms 1000.0))) (fmt elapsed-ms)]))))
(print-table!
(str "Publishing Throughput (" (name transport) ", n=" n ")")
["Batch Size" "Msgs/sec" "Total ms"]
@results)
(finally
(cleanup! id))))))
;;; ---------------------------------------- Benchmark 2: End-to-End Latency ----------------------------------------
(defn bench-e2e-latency!
"Measures end-to-end latency from publish to listener receipt via the full async pipeline.
Uses the real publish API including the publish buffer.
Publishes randomly to the 10 shared queues (or the shared topic) so multiple nodes
compete for the same messages.
Options:
:n - number of messages to publish (default 50)
:rate - :low, :medium, :high, or :burst (default :medium)
:transport - :queue or :topic (default :queue)"
([] (bench-e2e-latency! {}))
([{:keys [n rate transport]
:or {n 50 rate :medium transport :queue}}]
(ensure-started!)
(let [latencies (atom [])
received (atom 0)
channels (if (= transport :queue) shared-queues [shared-topic])]
(try
;; Register listeners on all shared channels
(doseq [ch channels]
(when-not (listener/get-listener ch)
(listener/batch-listen! ch
(fn [msgs]
(doseq [msg msgs]
(let [receive-ns (System/nanoTime)
publish-ns (get msg "publish-ns")]
(when publish-ns
(swap! latencies conj (/ (- receive-ns (double publish-ns)) 1e6))))
(swap! received inc)))
{})))
(let [msgs-sec (rate->msgs-sec rate)
start (System/nanoTime)]
;; Publish to random shared channels
(publish-at-rate!
n msgs-sec
(fn [i]
(let [ch (if (= transport :queue) (random-shared-queue) shared-topic)]
(if (= transport :queue)
(mq/with-queue ch [q]
(mq/put q {:seq i :publish-ns (System/nanoTime)}))
(mq/with-topic ch [t]
(mq/put t {:seq i :publish-ns (System/nanoTime)}))))))
;; Force flush the publish buffer then wait for delivery
(publish-buffer/flush-publish-buffer!)
;; Wait until we've received at least n messages or timeout
(let [deadline (+ (System/currentTimeMillis)
(long (* 1000 (+ 30 (/ n (max 1 (or msgs-sec 1000)))))))]
(while (and (< @received n)
(< (System/currentTimeMillis) deadline))
(Thread/sleep (long 100))))
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)
stats (collect-stats @latencies)]
(print-table!
(str "End-to-End Latency (" (name transport) ", rate=" (name rate) ", n=" n ")")
["Metric" "Value"]
[["Published" (str n)]
["Received" (str @received)]
["Other nodes" (str (max 0 (- n @received)))]
["From others" (str (max 0 (- @received n)))]
["Total ms" (fmt elapsed-ms)]
["P50 ms" (fmt (:p50 stats))]
["P95 ms" (fmt (:p95 stats))]
["P99 ms" (fmt (:p99 stats))]
["Min ms" (fmt (:min stats))]
["Max ms" (fmt (:max stats))]
["Avg ms" (fmt (:avg stats))]])))
(finally
(doseq [ch channels]
(when (listener/get-listener ch)
(mq/unlisten! ch))))))))
;;; ---------------------------------------- Benchmark 3: Sustained Throughput ----------------------------------------
(defn bench-sustained-throughput!
"Publishes continuously while consuming for a given duration via the full async pipeline.
Publishes randomly to the 10 shared queues (or shared topic).
Options:
:duration-sec - how long to publish (default 15)
:rate - :low, :medium, :high, or :burst (default :medium)
:transport - :queue or :topic (default :queue)"
([] (bench-sustained-throughput! {}))
([{:keys [duration-sec rate transport]
:or {duration-sec 15 rate :medium transport :queue}}]
(ensure-started!)
(let [channels (if (= transport :queue) shared-queues [shared-topic])
received (atom 0)
latencies (atom [])
published (atom 0)
stop? (atom false)]
(try
;; Register listeners on all shared channels
(doseq [ch channels]
(when-not (listener/get-listener ch)
(listener/batch-listen! ch
(fn [msgs]
(doseq [msg msgs]
(let [receive-ns (System/nanoTime)
publish-ns (get msg "publish-ns")]
(when publish-ns
(swap! latencies conj (/ (- receive-ns (double publish-ns)) 1e6)))
(swap! received inc))))
{})))
(let [msgs-sec (rate->msgs-sec rate)
start (System/nanoTime)
pub-thread (Thread.
(fn []
(loop []
(when-not @stop?
(try
(let [ch (if (= transport :queue) (random-shared-queue) shared-topic)]
(if (= transport :queue)
(mq/with-queue ch [q]
(mq/put q {:seq @published :publish-ns (System/nanoTime)}))
(mq/with-topic ch [t]
(mq/put t {:seq @published :publish-ns (System/nanoTime)}))))
(swap! published inc)
(catch Exception _))
(when msgs-sec
(Thread/sleep (long (/ 1000 msgs-sec))))
(recur)))))]
(.start pub-thread)
(Thread/sleep (long (* duration-sec 1000)))
(reset! stop? true)
(.join pub-thread 5000)
;; Flush publish buffer and wait for remaining messages to drain
(publish-buffer/flush-publish-buffer!)
(let [drain-start (System/currentTimeMillis)]
(while (and (< @received @published)
(< (- (System/currentTimeMillis) drain-start) 30000))
(publish-buffer/flush-publish-buffer!)
(Thread/sleep (long 500))))
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)
stats (collect-stats @latencies)]
(print-table!
(str "Sustained Throughput (" (name transport) ", rate=" (name rate)
", duration=" duration-sec "s)")
["Metric" "Value"]
[["Published" (str @published)]
["Received" (str @received)]
["Other nodes" (str (max 0 (- @published @received)))]
["From others" (str (max 0 (- @received @published)))]
["Duration ms" (fmt elapsed-ms)]
["Actual pub/sec" (fmt (/ @published (/ elapsed-ms 1000.0)))]
["Actual recv/sec" (fmt (/ @received (/ elapsed-ms 1000.0)))]
["P50 latency ms" (fmt (:p50 stats))]
["P95 latency ms" (fmt (:p95 stats))]
["P99 latency ms" (fmt (:p99 stats))]
["Max latency ms" (fmt (:max stats))]])))
(finally
(doseq [ch channels]
(when (listener/get-listener ch)
(mq/unlisten! ch))))))))
;;; ---------------------------------------- Benchmark 4: Batch Listener ----------------------------------------
(defn bench-batch-listener!
"Compares single-message vs batch listener processing rates.
Uses the shared queues so multiple nodes compete.
Options:
:n - total messages to publish (default 200)
:batch-sizes - vector of max-batch-messages values to test (default [1 10 50])"
([] (bench-batch-listener! {}))
([{:keys [n batch-sizes]
:or {n 200 batch-sizes [1 10 50]}}]
(ensure-started!)
(let [results (atom [])]
(doseq [bs batch-sizes]
(let [received (atom 0)]
(try
;; Register listeners on all shared queues
(doseq [ch shared-queues]
(when-not (listener/get-listener ch)
(listener/batch-listen! ch
(fn [msgs]
(swap! received + (count msgs)))
{:max-batch-messages bs})))
;; Publish all messages randomly to shared queues
(dotimes [i n]
(mq/with-queue (random-shared-queue) [q]
(mq/put q {:seq i})))
(publish-buffer/flush-publish-buffer!)
;; Wait for messages to be consumed
(let [start (System/nanoTime)
deadline (+ (System/currentTimeMillis) 30000)]
(while (and (< @received n)
(< (System/currentTimeMillis) deadline))
(Thread/sleep (long 100)))
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)]
(swap! results conj
[(str bs) (str @received) (fmt elapsed-ms)
(fmt (/ @received (/ elapsed-ms 1000.0)))])))
(finally
(doseq [ch shared-queues]
(when (listener/get-listener ch)
(mq/unlisten! ch)))))))
(print-table!
(str "Batch Listener Comparison (n=" n ")")
["Max Batch" "Received" "Process ms" "Msgs/sec"]
@results))))
;;; ---------------------------------------- Benchmark 5: Multiple Queues ----------------------------------------
(defn bench-multi-queue!
"Measures performance with varying numbers of the shared queues.
Tests with subsets of the 10 shared queues to show scaling.
Options:
:queue-counts - vector of queue counts to test (default [1 5 10])
:msgs-per-queue - messages per queue (default 50)"
([] (bench-multi-queue! {}))
([{:keys [queue-counts msgs-per-queue]
:or {queue-counts [1 5 10] msgs-per-queue 50}}]
(ensure-started!)
(let [results (atom [])]
(doseq [qc queue-counts]
(let [channels (subvec shared-queues 0 (min qc (count shared-queues)))
total (* (count channels) msgs-per-queue)
received (atom 0)]
(try
;; Register listeners
(doseq [ch channels]
(when-not (listener/get-listener ch)
(listener/batch-listen! ch
(fn [msgs]
(swap! received + (count msgs)))
{})))
;; Publish to the queues
(let [start (System/nanoTime)]
(doseq [ch channels]
(dotimes [i msgs-per-queue]
(mq/with-queue ch [q]
(mq/put q {:seq i}))))
(publish-buffer/flush-publish-buffer!)
;; Wait for messages
(let [deadline (+ (System/currentTimeMillis) 60000)]
(while (and (< @received total)
(< (System/currentTimeMillis) deadline))
(Thread/sleep (long 100))))
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)]
(swap! results conj
[(str (count channels)) (str total) (str @received) (fmt elapsed-ms)
(fmt (/ @received (/ elapsed-ms 1000.0)))])))
(finally
(doseq [ch channels]
(when (listener/get-listener ch)
(mq/unlisten! ch)))))))
(print-table!
(str "Multiple Queues (msgs-per-queue=" msgs-per-queue ")")
["Queues" "Total Msgs" "Received" "Total ms" "Msgs/sec"]
@results))))
;;; ---------------------------------------- Benchmark 6: Concurrent Consumers (LATERAL/SKIP LOCKED) ----------------------------------------
(defn bench-concurrent-consumers!
"Validates the LATERAL/SKIP LOCKED fetch and exactly-once delivery under multi-consumer
contention by spinning up multiple `AppDbQueueBackend` instances in the same JVM, each
with its own listener atom and poll thread. All instances share the queue and DB.
What this actually measures:
- **Correctness**: every row is processed exactly once (`Dups` should always be 0; the
`Imbalance` shows how the work spread but the total received must equal `n`).
- **Per-row contention**: `mq.impl/active-handlers` is a JVM-global atom, so at most ONE
consumer can be actively handling the channel at a time within a single JVM. The other
consumers see `channel-busy?` and skip the queue. So this test does *not* validate
cross-node throughput scaling — for that you need separate JVMs.
- **Buffer-vs-row behavior**: `*publish-buffer-ms*` is forced to 0 so each `put` becomes
its own row; otherwise the buffer collapses all `n` messages into a single row that
only one consumer can grab.
Options:
:consumer-counts - vector of consumer counts to test (default [1 2 4 8])
:n - total messages per run (default 2000)
:queue-name - queue to use (default :queue/bench-concurrent)"
([] (bench-concurrent-consumers! {}))
([{:keys [consumer-counts n queue-name]
:or {consumer-counts [1 2 4 8] n 2000 queue-name :queue/bench-concurrent}}]
(ensure-started!)
(let [results (atom [])]
(doseq [c consumer-counts]
(let [per-consumer-received (vec (repeatedly c #(atom 0)))
seen-batch-ids (atom #{})
duplicates (atom 0)
backends (vec (repeatedly c q.appdb/make-backend))
listener-atoms (vec (repeatedly c #(atom {})))]
(try
(doseq [i (range c)]
(binding [listener/*listeners* (nth listener-atoms i)]
(listener/batch-listen! queue-name
(fn [msgs]
(doseq [m msgs]
(let [bid (get m "batch-id" m)]
(when (contains? @seen-batch-ids bid)
(swap! duplicates inc))
(swap! seen-batch-ids conj bid)))
(swap! (nth per-consumer-received i) + (count msgs)))
{})
(q.backend/start! (nth backends i))))
(let [start (System/nanoTime)]
;; Publish via root listener atom (so the buffer flush sees them). Bypass the
;; time-windowed publish buffer (set *publish-buffer-ms* to 0) so each `put`
;; becomes its own DB row — otherwise all `n` messages collapse into one row
;; and there's nothing for multiple consumers to fetch in parallel.
(binding [listener/*listeners* (atom {queue-name {:listener identity
:max-batch-messages 100}})
publish-buffer/*publish-buffer-ms* 0]
(dotimes [i n]
(mq/with-queue queue-name [q]
(mq/put q {:id i})))
(publish-buffer/flush-publish-buffer!))
(let [total (atom 0)
deadline (+ (System/currentTimeMillis) 60000)]
(while (and (< @total n)
(< (System/currentTimeMillis) deadline))
;; Notify all poll threads so consumer backends drain promptly. In production
;; the publish-side notify reaches the same node; multi-instance benchmarks
;; sit between nodes where no cross-node wakeup exists.
(polling/notify-all!)
(reset! total (reduce + (map deref per-consumer-received)))
(Thread/sleep 50))
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)
received-per-consumer (mapv deref per-consumer-received)
min-c (apply min received-per-consumer)
max-c (apply max received-per-consumer)
spread (when (pos? max-c)
(* 100.0 (- 1.0 (/ (double min-c) max-c))))]
(swap! results conj
[(str c)
(str @total)
(str (apply max received-per-consumer))
(str (apply min received-per-consumer))
(if spread (format "%.1f%%" spread) "n/a")
(str @duplicates)
(fmt elapsed-ms)
(fmt (/ @total (/ elapsed-ms 1000.0)))]))))
(finally
;; Shut each backend's polling thread (per-instance, doesn't touch production)
(doseq [be backends] (q.backend/shutdown! be))
;; Clean DB rows for this queue
(t2/delete! :queue_message_batch :queue_name (name queue-name))))))
(print-table!
(str "Concurrent Consumers (n=" n " per run, queue=" (name queue-name) ")")
["Consumers" "Total" "Max/c" "Min/c" "Imbalance" "Dups" "Total ms" "Msgs/sec"]
@results))))
;;; ---------------------------------------- Benchmark 7: Stale-Recovery Race ----------------------------------------
(defn bench-stale-recovery-race!
"Pre-populates `:n` rows as `status=processing` with a stale heartbeat, then runs the
recovery function from `:workers` threads simultaneously. Validates that the guarded
bulk UPDATE delivers each row to exactly one winner regardless of concurrency.
Options:
:n - number of stale rows to seed (default 500)
:workers - concurrent recovery threads (default 8)"
([] (bench-stale-recovery-race! {}))
([{:keys [n workers] :or {n 500 workers 8}}]
(ensure-started!)
(let [queue-name (keyword "queue" (str "bench-stale-" (run-id)))
stale-ts (java.sql.Timestamp/from
(.minusMillis (java.time.Instant/now) (* 20 60 1000)))]
(try
(let [rows (vec (for [_ (range n)]
{:queue_name (name queue-name)
:messages "[]"
:status "processing"
:status_heartbeat stale-ts
:owner "dead-node"}))]
(t2/insert! :queue_message_batch rows))
(let [recoveries (atom [])
latch (CountDownLatch. 1)
threads (mapv (fn [_]
(let [r (bound-fn []
(.await latch)
(let [n-recovered (@#'q.appdb/recover-stale-processing-batches!)]
(swap! recoveries conj n-recovered)))]
(Thread. ^Runnable r)))
(range workers))
start (System/nanoTime)]
(run! #(.start ^Thread %) threads)
(.countDown latch)
(run! #(.join ^Thread % 30000) threads)
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)
total-rec (reduce + @recoveries)
final-state (frequencies (map :status (t2/select :queue_message_batch
:queue_name (name queue-name))))]
(print-table!
(str "Stale Recovery Race (n=" n " stale, workers=" workers ")")
["Metric" "Value"]
[["Sum of returned counts" (str total-rec)]
["Stale rows pre-populated" (str n)]
["Sum == n? (correctness)" (if (= total-rec n) "YES" (str "NO diff=" (- total-rec n)))]
["Final 'pending'" (str (get final-state "pending" 0))]
["Final 'failed'" (str (get final-state "failed" 0))]
["Final 'processing'" (str (get final-state "processing" 0))]
["Total wall-clock ms" (fmt elapsed-ms)]
["Per-row ms (effective)" (fmt (/ elapsed-ms (max 1 n)))]])))
(finally
(t2/delete! :queue_message_batch :queue_name (name queue-name)))))))
;;; ---------------------------------------- Benchmark 8: Concurrent Publish + Consume ----------------------------------------
(defn bench-concurrent-pub-consume!
"Runs a publisher thread and a consumer simultaneously against one queue, measuring
end-to-end latency under write contention. Unlike `bench-e2e-latency!`, the publisher and
the wait-for-receive happen on separate threads, so we exercise the actual
publish-while-consume code path (buffer atom contention, fetch contending with INSERT,
the per-channel `active-handlers` gate).
Each published message carries a `publish-ns` timestamp; the listener measures
`receive-ns - publish-ns` and records it as the per-message latency.
Options:
:duration-sec - how long to publish for (default 10)
:pub-rate-msgs-sec - target publish rate (default 50)
:queue-name - queue to use (default :queue/bench-pub-consume)"
([] (bench-concurrent-pub-consume! {}))
([{:keys [duration-sec pub-rate-msgs-sec queue-name]
:or {duration-sec 10 pub-rate-msgs-sec 50 queue-name :queue/bench-pub-consume}}]
(ensure-started!)
(let [received (atom 0)
latencies (atom [])
stop? (atom false)
publisher-published (atom 0)]
(try
(listener/batch-listen! queue-name
(fn [msgs]
(let [now (System/nanoTime)]
(doseq [m msgs]
(when-let [pub-ns (get m "publish-ns")]
(swap! latencies conj (/ (- now (double pub-ns)) 1e6))))
(swap! received + (count msgs))))
{})
(let [pub-runnable (bound-fn []
(let [interval-ns (long (/ 1e9 pub-rate-msgs-sec))
stop-at-ns (+ (System/nanoTime) (* 1e9 duration-sec))]
(loop []
(when (and (not @stop?) (< (System/nanoTime) stop-at-ns))
(let [next-pub-ns (+ (System/nanoTime) interval-ns)]
(mq/with-queue queue-name [q]
(mq/put q {:publish-ns (System/nanoTime)}))
(swap! publisher-published inc)
(let [sleep-ns (- next-pub-ns (System/nanoTime))]
(when (pos? sleep-ns)
(Thread/sleep (long (/ sleep-ns 1e6))
(int (mod sleep-ns 1e6)))))
(recur))))))
pub-thread (Thread. ^Runnable pub-runnable)
start (System/nanoTime)]
(.start pub-thread)
(.join pub-thread (long (* 1000 (+ duration-sec 1))))
(reset! stop? true)
;; Drain — wait for the consumer to catch up after publishing stops.
(let [drain-deadline (+ (System/currentTimeMillis) 30000)]
(while (and (< @received @publisher-published)
(< (System/currentTimeMillis) drain-deadline))
(polling/notify-all!)
(Thread/sleep 50)))
(let [elapsed-ms (/ (- (System/nanoTime) start) 1e6)
stats (collect-stats @latencies)]
(print-table!
(str "Concurrent Pub+Consume (rate=" pub-rate-msgs-sec "/sec, duration=" duration-sec "s"
(when @rtt-ms (str ", rtt=" (first @rtt-ms) "-" (last @rtt-ms) "ms"))
")")
["Metric" "Value"]
[["Published" (str @publisher-published)]
["Received" (str @received)]
["Drain ms" (fmt (- elapsed-ms (* 1000 duration-sec)))]
["P50 latency ms" (fmt (:p50 stats))]
["P95 latency ms" (fmt (:p95 stats))]
["P99 latency ms" (fmt (:p99 stats))]
["Max latency ms" (fmt (:max stats))]
["Pub/sec (actual)" (fmt (/ @publisher-published (double duration-sec)))]
["Recv/sec total" (fmt (/ @received (/ elapsed-ms 1000.0)))]])))
(finally
(reset! stop? true)
(mq/unlisten! queue-name)
(t2/delete! :queue_message_batch :queue_name (name queue-name)))))))
;;; ---------------------------------------- Run All ----------------------------------------
(defn run-all-benchmarks!
"Runs all benchmark scenarios with default parameters."
[]
(println "=== MQ Performance Benchmarks ===")
(println)
(println "--- 1/5: Publishing Throughput (Queue) ---")
(bench-publish-throughput! {:n 1000 :transport :queue})
(println "--- 1b/5: Publishing Throughput (Topic) ---")
(bench-publish-throughput! {:n 1000 :transport :topic})
(println "--- 2/5: End-to-End Latency ---")
(doseq [rate [:low :medium :high :burst]]
(bench-e2e-latency! {:n 50 :rate rate :transport :queue}))
(doseq [rate [:low :medium :burst]]
(bench-e2e-latency! {:n 50 :rate rate :transport :topic}))
(println "--- 3/5: Sustained Throughput ---")
(bench-sustained-throughput! {:duration-sec 15 :rate :medium :transport :queue})
(bench-sustained-throughput! {:duration-sec 15 :rate :medium :transport :topic})
(println "--- 4/5: Batch Listener Comparison ---")
(bench-batch-listener! {:n 200})
(println "--- 5/7: Multiple Queues ---")
(bench-multi-queue! {:queue-counts [1 5 10 20] :msgs-per-queue 50})
(println "--- 6/7: Concurrent Consumers ---")
(bench-concurrent-consumers! {:consumer-counts [1 2 4 8] :n 2000})
(println "--- 7/7: Stale Recovery Race ---")
(bench-stale-recovery-race! {:n 500 :workers 8})
(println "=== All benchmarks complete ==="))
;;; ---------------------------------------- Multi-Node Coordination ----------------------------------------
(defn run-all-benchmarks-coordinated!
"Waits for a 'go' signal on :topic/bench-coordination, then runs all benchmarks.
Use with multiple JVM instances to start benchmarks simultaneously.
Usage:
;; On each node:
(start-mq!)
(run-all-benchmarks-coordinated!)
;; Then from any node or separate REPL:
(signal-go!)"
[]
(let [latch (CountDownLatch. 1)]
(listener/batch-listen! :topic/bench-coordination
(fn [_msgs] (.countDown latch))
{})
(println "Waiting for go signal... (call (signal-go!) from any node)")
(.await latch 300 TimeUnit/SECONDS)
(mq/unlisten! :topic/bench-coordination)
(println "Go signal received! Starting benchmarks...")
(run-all-benchmarks!)))
(defn signal-go!
"Sends the go signal to all waiting nodes."
[]
(mq/with-topic :topic/bench-coordination [t]
(mq/put t {:go true :time (System/currentTimeMillis)}))
(println "Go signal sent."))

104
dev/src/dev/mq_perf_multi.sh Executable file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Runs multiple MQ benchmark nodes against the same database.
#
# Usage:
# ./dev/src/dev/mq_perf_multi.sh start # Start 2 nodes (default)
# ./dev/src/dev/mq_perf_multi.sh start 4 # Start 4 nodes
# ./dev/src/dev/mq_perf_multi.sh go # Send go signal to all waiting nodes
# ./dev/src/dev/mq_perf_multi.sh stop # Stop all running nodes
# ./dev/src/dev/mq_perf_multi.sh logs # Tail all node logs
#
# Each node starts a lightweight JVM with just DB + MQ (no server),
# waits for a coordinated go signal, then runs all benchmarks.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
LOG_DIR="target/mq-perf-logs"
PID_DIR="target/mq-perf-pids"
cmd_start() {
local num_nodes="${1:-2}"
mkdir -p "$LOG_DIR" "$PID_DIR"
# Clean up stale pids
rm -f "$PID_DIR"/*.pid
echo "Starting $num_nodes MQ benchmark nodes..."
echo "Logs in $LOG_DIR/"
echo
for i in $(seq 1 "$num_nodes"); do
local log_file="$LOG_DIR/node-$i.log"
clojure -M:dev:ee:ee-dev -e "
(require 'dev.mq-perf)
(dev.mq-perf/start-mq!)
(dev.mq-perf/run-all-benchmarks-coordinated!)
" > "$log_file" 2>&1 &
local pid=$!
echo "$pid" > "$PID_DIR/node-$i.pid"
echo " Node $i started (PID $pid, log: $log_file)"
done
echo
echo "Nodes are initializing. Once ready, run:"
echo " $0 go"
}
cmd_go() {
echo "Sending go signal..."
clojure -M:dev:ee:ee-dev -e "
(require 'dev.mq-perf)
(dev.mq-perf/start-mq!)
(dev.mq-perf/signal-go!)
"
echo
echo "Benchmarks started. Watch progress with:"
echo " $0 logs"
}
cmd_stop() {
if [ ! -d "$PID_DIR" ]; then
echo "No nodes running."
return
fi
for pid_file in "$PID_DIR"/*.pid; do
[ -f "$pid_file" ] || continue
local pid
pid=$(cat "$pid_file")
local name
name=$(basename "$pid_file" .pid)
if kill -0 "$pid" 2>/dev/null; then
kill "$pid"
echo "Stopped $name (PID $pid)"
else
echo "$name (PID $pid) already exited"
fi
rm -f "$pid_file"
done
}
cmd_logs() {
if [ ! -d "$LOG_DIR" ]; then
echo "No logs found."
return
fi
tail -f "$LOG_DIR"/node-*.log
}
case "${1:-}" in
start) cmd_start "${2:-2}" ;;
go) cmd_go ;;
stop) cmd_stop ;;
logs) cmd_logs ;;
*)
echo "Usage: $0 {start [N]|go|stop|logs}"
echo
echo " start [N] Start N benchmark nodes (default 2)"
echo " go Send go signal to start benchmarks"
echo " stop Kill all running nodes"
echo " logs Tail all node logs"
exit 1
;;
esac

View File

@@ -6,6 +6,29 @@ title: Driver interface changelog
## Metabase 0.61.0
- Added the following driver multimethods to support MBQL5 compilation migration:
- `sql.qp/compile-mbql` - Compiles an MBQL inner query to HoneySQL.
- `sql.qp/mbql-clause-with-opts` - Returns an MBQL clause in the desired MBQL format of the driver.
- `sql.qp/expression-by-name` - Gets an expression from a query or stage (`*inner-query`) by name.
- `sql.qp/aggregation-name` - Returns the name of an aggregation clause.
- `sql.qp/over-order-by->honeysql` - Returns the HoneySQL for an order by clause in the over clause of a window function.
- `sql.qp/clause-value-idx` - Returns the index of the value in a value clause.
- `sql.qp/breakout-options-index` - Returns the index of options in a breakout clause.
- `sql.params.substitution/field->clause` - Returns an MBQL field clause with the given options.
- `sql.params.substitution/to-clause` - Helper to dispatch to `params.ops/to-clause` or `qp.params.ops/to-clause`.
- `sql.params.substitution/desugar-filter-clause` - Helper to dispatch to `driver-api/desugar-filter-clause` or `lib/desugar-filter-clause`.
- `sql.params.substitution/wrap-value-literals-in-mbql` - Helper to dispatch to `driver-api/wrap-value-literals-in-mbql` or `driver-api/wrap-value-literals-in-mbql5`.
- `sql.params.substitution/date-string->filter` - Helper to dispatch to `params.dates/date-string->filter` or `qp.params.dates/date-string->filter`.
These methods have implementations for the `:sql` and `:sql-mbql5` drivers. Concrete drivers should *not* need to
implement these methods. Drivers can opt-in to MBQL5 compilation by adding the `:sql-mbql5` driver as a parent, and updating the `sql.qp/->honeysql`
methods to handle the clause options argument as the second parameter. See the `:h2` driver in https://github.com/metabase/metabase/pull/71439 for
an example. Drivers will need to be migrated to work with MBQL5 compilation over the next three releases by v64. After v64 these methods will be deprecated
in favour of the `:sql-mbql5` implementations once all drivers have been migrated.
- Added a `driver` parameter to `sql.qp/maybe-cast-uuid-for-text-compare`. Any drivers that call this function should
update it to pass in the `driver` parameter now. An example is in the Snowflake driver's `string-filter` function.
- `driver/field-reference-mlv2`, deprecated in 0.57.0, has now been removed.
- `metabase.driver.sql/set-role-statement` has been deprecated in favor of

View File

@@ -106,7 +106,8 @@ You can set different attributes to enable/disable UI. Here are some example att
| `token` | Required. The signed JWT token from your server. |
| `with-title` | Show or hide the title. Values: `"true"` or `"false"`. |
| `with-downloads`\* | Enable or disable downloads. Values: `"true"` or `"false"`. |
| `initial-parameters` | JSON string of parameter values. Example: `'{"category":["Gizmo"]}'`. |
| `initial-parameters` | JSON string of initial parameter values (uncontrolled). Example: `'{"category":["Gizmo"]}'`. See [Modular embedding parameters](./parameters.md#pass-parameter-values-to-embedded-components). |
| `parameters` | JSON string of parameter values (controlled). Example: `'{"category":["Gizmo"]}'`. See [Modular embedding parameters](./parameters.md#pass-parameter-values-to-embedded-components). |
| `auto-refresh-interval` | Dashboards only. Auto-refresh interval in seconds. |
| `custom-context` | Forwarded to your [`guestEmbedProviderUri`](#refreshing-or-initializing-the-jwt-from-your-server) endpoint as `customContext`. Either a string (e.g., `"gadgets-tab"`), or a JSON-stringified object like `initial-parameters` (e.g., `'{"tab":"gadgets","region":"us-east"}'`). |
@@ -190,6 +191,8 @@ Fetch the JWT token from your backend and programmatically pass it to the 'metab
</metabase-dashboard>
```
See [Modular embedding parameters](./parameters.md#pass-parameter-values-to-embedded-components) for controlled parameters documentation.
### Locked parameters
Locked parameters let you filter data without exposing the filter to the end-user. Locking parameters is useful for restricting data based on who's viewing the embed (for example, showing each customer only their own data).

View File

@@ -176,6 +176,10 @@ When you're creating a new embed using **Admin > Embedding > Setup guide > Embed
- **Allow alerts**: lets people set up [alerts](../questions/alerts.md) on embedded questions. Requires [email setup](../configuring-metabase/email.md). Only for authenticated (SSO) question embeds.
## Pass parameter values to embedded components
See [Modular embedding parameters](./parameters.md#pass-parameter-values-to-embedded-components).
## Page-level config
To define the configuration that applies to every embed on the page, use the `defineMetabaseConfig()` function. Its parameters include:

View File

@@ -0,0 +1,175 @@
---
title: Modular embedding parameters
summary: Pass parameter values to embedded dashboards and SQL questions in modular embedding (React SDK and web components).
---
# Modular embedding parameters
This page covers how to pass parameter values to embedded dashboards and SQL questions.
## Modular embedding SDK (React)
### Pass parameter values to a dashboard
You can set initial values for embeds (uncontrolled), and optionally keep your app in sync with values as people change them (controlled). You can pick either `initialParameters` and `parameters`, but don't combine them.
#### `initialParameters` (uncontrolled)
Set the filter values once on load. Your app won't know when people change filters in the dashboard. Pick this when you don't need to track those changes.
```typescript
{% include_file "{{ dirname }}/snippets/parameters/dashboards/initial-parameters.tsx" snippet="example" %}
```
See [How parameter values are resolved](#how-parameter-values-are-resolved) for null / missing-slug semantics.
#### `parameters` + `onParametersChange` (controlled)
Push values from your app, and observe every applied change via `onParametersChange`. This works like a controlled `<input value onChange>`. Your app holds the source of truth, the dashboard re-renders when the prop changes, and you receive a callback whenever applied values change.
```typescript
{% include_file "{{ dirname }}/snippets/parameters/dashboards/controlled-parameters.tsx" snippet="example-controlled" %}
```
`onParametersChange` receives the [dashboard parameter change payload](#dashboard-parameter-change-payload).
> Don't combine `initialParameters` and `parameters` - pick one. For controlled behavior, only use `parameters`.
### Pass parameters to SQL questions
You can pass [parameter](../questions/native-editor/sql-parameters.md) values to SQL questions in the format `{parameter_name: parameter_value}`. You can set initial values for embeds (uncontrolled), and optionally keep your app in sync with values as people change them (controlled).
These props only work with SQL questions, not query-builder questions.
#### `initialSqlParameters` (uncontrolled)
Set the parameter values once on load. Your app won't know when people change parameters in the question. Pick this when you don't need to track those changes.
```typescript
{% include_file "{{ dirname }}/snippets/parameters/questions/initial-sql-parameters.tsx" snippet="example" %}
```
See [How parameter values are resolved](#how-parameter-values-are-resolved) for null / missing-slug semantics.
#### `sqlParameters` + `onSqlParametersChange` (controlled)
Push values from your app, and observe every applied change via `onSqlParametersChange`. This works like a controlled `<input value onChange>` - your app holds the source of truth, the question re-renders when the prop changes, and you receive a callback whenever applied values change.
```typescript
{% include_file "{{ dirname }}/snippets/parameters/questions/controlled-sql-parameters.tsx" snippet="example-controlled" %}
```
`onSqlParametersChange` receives the [SQL question parameter change payload](#sql-question-parameter-change-payload).
## Modular embedding (web components)
### Pass parameter values to embedded components
You can set dashboard filters and SQL parameters from your page, push new values at runtime, and listen for applied changes.
#### Seed values once with `initial-parameters` / `initial-sql-parameters`
Set values on mount via attributes. The component reads them once on load and ignores any subsequent changes to the attribute. User widget edits are not reflected back to your page.
```html
<metabase-dashboard
dashboard-id="1"
initial-parameters='{"state": "NY"}'
></metabase-dashboard>
<metabase-question
question-id="42"
initial-sql-parameters='{"product_id": 50}'
></metabase-question>
```
Attributes carry JSON. Pass an object whose keys are parameter slugs (dashboards) or SQL variable names (questions). See [How parameter values are resolved](#how-parameter-values-are-resolved) for null / missing-slug semantics.
#### Push values at runtime with `parameters` / `sqlParameters`
For controlled behavior, set the JS property on the element instead of the attribute. The component re-renders to apply the new values.
```html
<metabase-dashboard id="my-dashboard" dashboard-id="1"></metabase-dashboard>
<script>
const el = document.getElementById("my-dashboard");
el.parameters = { state: "NY" };
</script>
```
The same pattern works for `metabase-question` via the `sqlParameters` property.
To switch a component back to uncontrolled mode (leaving the last applied values in place), set the property to `undefined`.
#### Clearing parameters
To clear a single parameter, set its value to `null`. This strictly clears the parameter and ignores its default value.
```html
<script>
const el = document.getElementById("my-dashboard");
// `null` strictly clears the parameter (ignores its default).
el.parameters = { ...el.parameters, state: null };
</script>
```
To clear every parameter, assign an empty object `{}`.
```html
<script>
const el = document.getElementById("my-dashboard");
el.parameters = {};
</script>
```
#### Observe applied changes with `parameters-change` / `sql-parameters-change`
Listen for events to keep your page in sync with what's actually applied:
```html
<metabase-dashboard id="my-dashboard" dashboard-id="1"></metabase-dashboard>
<script>
const el = document.getElementById("my-dashboard");
el.addEventListener("parameters-change", (event) => {
const { source, parameters, defaultParameters, lastUsedParameters } =
event.detail;
console.log(source, parameters);
});
</script>
```
The `event.detail` carries the [dashboard parameter change payload](#dashboard-parameter-change-payload).
For SQL questions, listen for `sql-parameters-change` on `<metabase-question>`. Its `event.detail` carries the [SQL question parameter change payload](#sql-question-parameter-change-payload).
## How parameter values are resolved
These rules apply to all four props — `initialParameters` / `parameters` (dashboards) and `initialSqlParameters` / `sqlParameters` (SQL questions) — and to the matching web component attributes (`initial-parameters`, `parameters`, etc.). For each parameter slug:
- **Set a value**: Pass a `string` for a single-option filter, and an array of `string`s for multi-option filters.
- **Clear a value:** Set to `null`: the parameter is cleared and its default is not used.
- **Reset to the default value**: Omit a value (or set to `undefined`) and the embed will fall back to the parameter's default (or `null` if it has no default).
## Dashboard parameter change payload
Delivered to `onParametersChange` (SDK) and as `event.detail` for the `parameters-change` event (web components).
{% include_file "{{ dirname }}/sdk/api/snippets/ParameterChangePayload.md" snippet="properties" %}
`source` indicates why the callback fired:
{% include_file "{{ dirname }}/sdk/api/snippets/ParameterChangeSource.md" %}
## SQL question parameter change payload
Delivered to `onSqlParametersChange` (SDK) and as `event.detail` for the `sql-parameters-change` event (web components).
{% include_file "{{ dirname }}/sdk/api/snippets/SqlParameterChangePayload.md" snippet="properties" %}
`source` indicates why the callback fired:
{% include_file "{{ dirname }}/sdk/api/snippets/SqlParameterChangeSource.md" %}

View File

@@ -60,6 +60,10 @@ A dashboard component with the features available in the `InteractiveDashboard`
{% include_file "{{ dirname }}/snippets/dashboards/interactive-dashboard.tsx" %}
```
## Pass parameter values to a dashboard
See [Modular embedding parameters](../parameters.md#pass-parameter-values-to-a-dashboard).
## Customizing dashboard height
By default, dashboard components take full page height (100vh). You can override this with custom styles passed via `style` or `className` props.

View File

@@ -60,15 +60,9 @@ Use this component when you want to allow people to explore their data and custo
{% include_file "{{ dirname }}/api/snippets/InteractiveQuestionProps.md" snippet="properties" %}
## Pass SQL parameters to SQL questions with `initialSqlParameters`
## Pass parameters to SQL questions
You can pass parameter values to questions defined with SQL via the `initialSqlParameters` prop, in the format of `{parameter_name: parameter_value}`. Learn more about [SQL parameters](../../questions/native-editor/sql-parameters.md).
```typescript
{% include_file "{{ dirname }}/snippets/questions/initial-sql-parameters.tsx" snippet="example" %}
```
`initialSqlParameters` can't be used with questions built using the query builder.
See [Modular embedding parameters](../parameters.md#pass-parameters-to-sql-questions).
## Enable alerts on embedded questions

View File

@@ -0,0 +1,52 @@
import {
InteractiveDashboard,
type ParameterChangePayload,
type ParameterValues,
} from "@metabase/embedding-sdk-react";
import { useState } from "react";
const dashboardId = 1;
const ExampleControlled = () => {
// [<snippet example-controlled>]
const [parameters, setParameters] = useState<ParameterValues>({
state: "NY",
});
const handleParametersChange = (payload: ParameterChangePayload) => {
// Sync your local state on every applied change. `payload.source` is one of:
// "initial-state" — post-load snapshot, fired once per dashboard load
// "manual-change" — user edited a parameter widget
// "auto-change" — your push was normalized; re-sync from `payload.parameters`
setParameters(payload.parameters);
};
return (
<InteractiveDashboard
dashboardId={dashboardId}
parameters={parameters}
onParametersChange={handleParametersChange}
/>
);
// [<endsnippet example-controlled>]
};
const ExampleClearOne = () => (
// [<snippet example-clear-one>]
// Setting a parameter to `null` clears it (ignores the parameter's default).
// Missing slugs fall back to `parameter.default ?? null`.
<InteractiveDashboard
dashboardId={dashboardId}
parameters={{ state: null, city: "Austin" }}
/>
// [<endsnippet example-clear-one>]
);
const ExampleClearAll = () => (
// [<snippet example-clear-all>]
// Pass an empty object to clear every parameter.
<InteractiveDashboard dashboardId={dashboardId} parameters={{}} />
// [<endsnippet example-clear-all>]
);
export { ExampleControlled, ExampleClearOne, ExampleClearAll };

View File

@@ -0,0 +1,14 @@
import { InteractiveDashboard } from "@metabase/embedding-sdk-react";
const dashboardId = 1;
const Example = () => (
// [<snippet example>]
<InteractiveDashboard
dashboardId={dashboardId}
initialParameters={{ state: "NY" }}
/>
// [<endsnippet example>]
);
export { Example };

View File

@@ -0,0 +1,44 @@
import {
InteractiveQuestion,
type SqlParameterChangePayload,
type SqlParameterValues,
} from "@metabase/embedding-sdk-react";
import { useState } from "react";
const questionId = 1;
const ExampleControlled = () => {
// [<snippet example-controlled>]
const [sqlParameters, setSqlParameters] = useState<SqlParameterValues>({
state: "NY",
});
const handleSqlParametersChange = (payload: SqlParameterChangePayload) => {
// Sync your local state on every applied change. `payload.source` is one of:
// "initial-state" — post-load snapshot, fired once per question load
// "manual-change" — user edited a parameter widget
// "auto-change" — your push was normalized; re-sync from `payload.parameters`
setSqlParameters(payload.parameters);
};
return (
<InteractiveQuestion
questionId={questionId}
sqlParameters={sqlParameters}
onSqlParametersChange={handleSqlParametersChange}
/>
);
// [<endsnippet example-controlled>]
};
const ExampleClear = () => (
// [<snippet example-clear>]
// Setting a SQL parameter to `null` clears it (ignores the parameter's default).
<InteractiveQuestion
questionId={questionId}
sqlParameters={{ state: null }}
/>
// [<endsnippet example-clear>]
);
export { ExampleControlled, ExampleClear };

View File

@@ -415,6 +415,71 @@ describe("scenarios > embedding-sdk > guest-embed-parameters", () => {
});
});
});
it("should apply controllable `sqlParameters` alongside a locked parameter", () => {
signInAsAdminAndSetupGuestEmbedding({
token: "starter",
});
createNativeQuestion(
{
name: "Orders native question",
native: {
query:
"SELECT * FROM ORDERS JOIN PEOPLE ON ORDERS.USER_ID = PEOPLE.ID WHERE {{quantity}} AND {{product_id_fk}} AND {{user_id_pk}}",
"template-tags": TEMPLATE_TAGS,
},
parameters: PARAMETERS,
enable_embedding: true,
embedding_params: {
quantity: "enabled",
product_id_fk: "locked",
user_id_pk: "enabled",
},
},
{
wrapId: true,
},
);
cy.signOut();
cy.intercept("GET", "/api/embed/card/*/params/*/values").as(
"parameterValuesApiRequest",
);
cy.get("@questionId").then(async (questionId) => {
const token = await getSignedJwtForResource({
resourceId: questionId as unknown as number,
resourceType: "question",
params: {
product_id_fk: 1,
},
});
mountGuestEmbedQuestion(
{
token,
sqlParameters: { user_id_pk: "74" },
},
{
shouldAssertCardQuery: false,
},
);
getSdkRoot().within(() => {
cy.findAllByTestId("parameter-widget").should("have.length", 2);
cy.findAllByTestId("parameter-widget")
.filter(':contains("PK->Name")')
.should("contain.text", "74");
cy.findByTestId("table-body")
.findAllByRole("row")
.should("have.length", 1);
});
});
});
});
describe("dashboard", () => {
@@ -833,5 +898,98 @@ describe("scenarios > embedding-sdk > guest-embed-parameters", () => {
});
});
});
it("should apply controllable `parameters` alongside a locked parameter", () => {
signInAsAdminAndSetupGuestEmbedding({
token: "starter",
});
createNativeQuestionAndDashboard({
questionDetails: {
name: "Orders native question",
native: {
query:
"SELECT * FROM ORDERS JOIN PEOPLE ON ORDERS.USER_ID = PEOPLE.ID WHERE {{quantity}} AND {{product_id_fk}} AND {{user_id_pk}}",
"template-tags": TEMPLATE_TAGS,
},
},
dashboardDetails: {
name: "Embedding SDK Test Dashboard",
embedding_type: "guest-embed",
parameters: PARAMETERS,
enable_embedding: true,
embedding_params: {
quantity: "enabled",
product_id_fk: "locked",
user_id_pk: "enabled",
},
},
}).then(({ body: { id: dashcardId, card_id, dashboard_id } }) => {
cy.request("PUT", `/api/dashboard/${dashboard_id}`, {
dashcards: [
{
id: dashcardId,
card_id,
row: 0,
col: 0,
size_x: 16,
size_y: 8,
parameter_mappings: [
{
parameter_id: "quantity",
card_id,
target: ["dimension", ["template-tag", "quantity"]],
},
{
parameter_id: "product_id_fk",
card_id,
target: ["dimension", ["template-tag", "product_id_fk"]],
},
{
parameter_id: "user_id_pk",
card_id,
target: ["dimension", ["template-tag", "user_id_pk"]],
},
],
},
],
});
cy.wrap(dashboard_id).as("dashboardId");
});
cy.signOut();
cy.intercept("GET", "/api/embed/dashboard/*/params/*/values").as(
"parameterValuesApiRequest",
);
cy.get("@dashboardId").then(async (dashboardId) => {
const token = await getSignedJwtForResource({
resourceId: dashboardId as unknown as number,
resourceType: "dashboard",
params: {
product_id_fk: 1,
},
});
mountGuestEmbedDashboard({
token,
parameters: { user_id_pk: "74" },
});
getSdkRoot().within(() => {
cy.findAllByTestId("parameter-widget").should("have.length", 2);
cy.findAllByTestId("parameter-widget")
.filter(':contains("PK->Name")')
.should("contain.text", "74");
cy.findByTestId("table-body")
.findAllByRole("row")
.should("have.length", 1);
});
});
});
});
});

View File

@@ -0,0 +1,467 @@
import {
InteractiveDashboard,
type ParameterValues,
} from "@metabase/embedding-sdk-react";
import { useState } from "react";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
ORDERS_DASHBOARD_DASHCARD_ID,
ORDERS_QUESTION_ID,
} from "e2e/support/cypress_sample_instance_data";
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import { mountSdkContent } from "e2e/support/helpers/embedding-sdk-component-testing";
import { signInAsAdminAndEnableEmbeddingSdk } from "e2e/support/helpers/embedding-sdk-testing";
import { mockAuthProviderAndJwtSignIn } from "e2e/support/helpers/embedding-sdk-testing/embedding-sdk-helpers";
import type {
ConcreteFieldReference,
DashboardCard,
Parameter,
} from "metabase-types/api";
const { H } = cy;
const { ORDERS } = SAMPLE_DATABASE;
const DATE_FILTER: Parameter = {
id: "2",
name: "Date filter",
slug: "filter-date",
type: "date/all-options",
};
const CREATED_AT_FIELD_REF: ConcreteFieldReference = [
"field",
ORDERS.CREATED_AT,
{ "base-type": "type/DateTime" },
];
const setup = () => {
signInAsAdminAndEnableEmbeddingSdk();
const questionCard: Partial<DashboardCard> = {
id: ORDERS_DASHBOARD_DASHCARD_ID,
parameter_mappings: [
{
parameter_id: DATE_FILTER.id,
card_id: ORDERS_QUESTION_ID,
target: ["dimension", CREATED_AT_FIELD_REF],
},
],
card_id: ORDERS_QUESTION_ID,
row: 0,
col: 0,
size_x: 16,
size_y: 8,
};
H.createDashboard({
name: "Controlled parameters dashboard",
dashcards: [questionCard],
parameters: [DATE_FILTER],
}).then(({ body: dashboard }) => {
cy.wrap(dashboard.id).as("dashboardId");
});
cy.signOut();
mockAuthProviderAndJwtSignIn();
};
const findDateFilterValue = () =>
getSdkRoot()
.findAllByTestId("parameter-widget")
.filter(`:contains("${DATE_FILTER.name}")`);
describe("scenarios > embedding-sdk > sdk-dashboard > controlled parameters", () => {
beforeEach(() => {
setup();
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
"dashcardQuery",
);
});
it("forwards values pushed via the `parameters` prop to the dashcard query", () => {
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(
<InteractiveDashboard
dashboardId={dashboardId}
parameters={{ "filter-date": "past30days" }}
/>,
);
});
cy.wait("@dashcardQuery").then(({ request }) => {
const parameters = request.body?.parameters ?? [];
expect(parameters).to.have.length(1);
expect(parameters[0]).to.include({
id: DATE_FILTER.id,
value: "past30days",
});
});
findDateFilterValue().should("contain.text", "Previous 30 days");
});
it("re-runs the dashcard query when the `parameters` prop reference changes", () => {
const SwitchableDashboard = ({ dashboardId }: { dashboardId: string }) => {
const [parameters, setParameters] = useState<ParameterValues>({
"filter-date": "past30days",
});
return (
<>
<button
type="button"
onClick={() => setParameters({ "filter-date": "thisyear" })}
>
switch to this year
</button>
<InteractiveDashboard
dashboardId={dashboardId}
parameters={parameters}
/>
</>
);
};
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(<SwitchableDashboard dashboardId={dashboardId} />);
});
cy.wait("@dashcardQuery").then(({ request }) => {
expect(request.body?.parameters?.[0]?.value).to.equal("past30days");
});
cy.contains("button", "switch to this year").click();
cy.wait("@dashcardQuery").then(({ request }) => {
expect(request.body?.parameters?.[0]?.value).to.equal("thisyear");
});
findDateFilterValue().should("contain.text", "This year");
});
it("fires `onParametersChange` with `source: 'initial-state'` exactly once on load", () => {
const onParametersChange = cy.spy().as("onParametersChange");
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(
<InteractiveDashboard
dashboardId={dashboardId}
onParametersChange={onParametersChange}
/>,
);
});
cy.wait("@dashcardQuery");
cy.get("@onParametersChange").should("have.been.calledOnce");
cy.get("@onParametersChange")
.its("firstCall.args.0")
.should("include", { source: "initial-state" });
});
it("delivers `parameters`, `defaultParameters`, `lastUsedParameters` fields in the payload", () => {
const onParametersChange = cy.spy().as("onParametersChange");
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(
<InteractiveDashboard
dashboardId={dashboardId}
parameters={{ "filter-date": "past30days" }}
onParametersChange={onParametersChange}
/>,
);
});
cy.wait("@dashcardQuery");
cy.get("@onParametersChange")
.its("firstCall.args.0")
.should((payload) => {
expect(payload.source).to.equal("initial-state");
expect(payload.parameters).to.deep.include({
"filter-date": "past30days",
});
expect(payload.defaultParameters).to.be.an("object");
expect(payload.lastUsedParameters).to.be.an("object");
});
});
it("fires `onParametersChange` with `source: 'manual-change'` when the user edits the filter widget", () => {
const onParametersChange = cy.spy().as("onParametersChange");
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(
<InteractiveDashboard
dashboardId={dashboardId}
onParametersChange={onParametersChange}
/>,
);
});
cy.wait("@dashcardQuery");
cy.get("@onParametersChange").should("have.been.calledOnce");
getSdkRoot().within(() => {
H.filterWidget().contains(DATE_FILTER.name).click();
});
H.popover().findByText("Previous 30 days").click();
cy.wait("@dashcardQuery");
cy.get("@onParametersChange")
.its("lastCall.args.0")
.should((payload) => {
expect(payload.source).to.equal("manual-change");
expect(payload.parameters).to.deep.include({
"filter-date": "past30days",
});
});
});
it("does not fire `onParametersChange` when pushed values are applied unchanged", () => {
const onParametersChange = cy.spy().as("onParametersChange");
const PushButton = ({ dashboardId }: { dashboardId: string }) => {
const [parameters, setParameters] = useState<ParameterValues>({
"filter-date": "past30days",
});
return (
<>
<button
type="button"
onClick={() => setParameters({ "filter-date": "thisyear" })}
>
push thisyear
</button>
<InteractiveDashboard
dashboardId={dashboardId}
parameters={parameters}
onParametersChange={onParametersChange}
/>
</>
);
};
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(<PushButton dashboardId={dashboardId} />);
});
cy.wait("@dashcardQuery");
cy.get("@onParametersChange")
.its("firstCall.args.0")
.should("include", { source: "initial-state" });
cy.contains("button", "push thisyear").click();
cy.wait("@dashcardQuery");
cy.wait(500);
cy.get("@onParametersChange").should("have.been.calledOnce");
});
it("fires `onParametersChange` with `source: 'auto-change'` when an unknown slug in the push is passed", () => {
const onParametersChange = cy.spy().as("onParametersChange");
const PushButton = ({ dashboardId }: { dashboardId: string }) => {
const [parameters, setParameters] = useState<ParameterValues>({
"filter-date": "past30days",
});
return (
<>
<button
type="button"
onClick={() =>
setParameters({
"filter-date": "thisyear",
"unknown-slug": "ignored",
})
}
>
push with unknown slug
</button>
<InteractiveDashboard
dashboardId={dashboardId}
parameters={parameters}
onParametersChange={onParametersChange}
/>
</>
);
};
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(<PushButton dashboardId={dashboardId} />);
});
cy.wait("@dashcardQuery");
cy.contains("button", "push with unknown slug").click();
cy.wait("@dashcardQuery");
// Host pushed `{ "filter-date": ..., "unknown-slug": ... }` but the
// dashboard only knows `filter-date`, so the payload omits
// `unknown-slug`. That mismatch fires `auto-change` so the host can
// sync from the actual applied state.
cy.get("@onParametersChange")
.its("lastCall.args.0")
.should((payload) => {
expect(payload.source).to.equal("auto-change");
expect(payload.parameters).to.deep.include({
"filter-date": "thisyear",
});
expect(payload.parameters).to.not.have.property("unknown-slug");
});
});
it("does not fire a redundant `manual-change` after `initial-state` when the seed equals the BE-resolved values", () => {
const onParametersChange = cy.spy().as("onParametersChange");
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(
<InteractiveDashboard
dashboardId={dashboardId}
parameters={{ "filter-date": "past30days" }}
onParametersChange={onParametersChange}
/>,
);
});
cy.wait("@dashcardQuery");
// Give the push hook's effect time to run; if it spuriously emits a
// `manual-change`, it would have happened by now.
cy.wait(500);
cy.get("@onParametersChange").should("have.been.calledOnce");
});
it("clears a single parameter when its value is set to null", () => {
const ClearableDashboard = ({ dashboardId }: { dashboardId: string }) => {
const [parameters, setParameters] = useState<ParameterValues>({
"filter-date": "past30days",
});
return (
<>
<button
type="button"
onClick={() =>
setParameters((prev) => ({ ...prev, "filter-date": null }))
}
>
clear date
</button>
<InteractiveDashboard
dashboardId={dashboardId}
parameters={parameters}
/>
</>
);
};
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(<ClearableDashboard dashboardId={dashboardId} />);
});
cy.wait("@dashcardQuery");
cy.contains("button", "clear date").click();
cy.wait("@dashcardQuery").then(({ request }) => {
// A cleared parameter may either be omitted from the body or sent
// with `value: null` — accept both as long as no filter is applied.
const dateParam = (request.body?.parameters ?? []).find(
(p: { id: string }) => p.id === DATE_FILTER.id,
);
expect(dateParam?.value ?? null).to.equal(null);
});
findDateFilterValue().should("not.contain.text", "Previous 30 days");
});
it("does not call `onParametersChange` when host clears the dashboard's only parameter to null", () => {
const onParametersChange = cy.spy().as("onParametersChange");
const ClearableDashboard = ({ dashboardId }: { dashboardId: string }) => {
const [parameters, setParameters] = useState<ParameterValues>({
"filter-date": "past30days",
});
return (
<>
<button
type="button"
onClick={() =>
setParameters((prev) => ({ ...prev, "filter-date": null }))
}
>
clear date
</button>
<InteractiveDashboard
dashboardId={dashboardId}
parameters={parameters}
onParametersChange={onParametersChange}
/>
</>
);
};
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(<ClearableDashboard dashboardId={dashboardId} />);
});
cy.wait("@dashcardQuery");
// initial-state has fired once.
cy.get("@onParametersChange").should("have.been.calledOnce");
cy.contains("button", "clear date").click();
// The clear push is dispatched (the dashcard re-runs), proving the
// controlled prop reached the dashboard.
cy.wait("@dashcardQuery");
cy.wait(500);
// Still exactly one call — clearing a single scalar param is a perfect
// round-trip (host's `null` ↔ payload's `null`), so no callback fires.
cy.get("@onParametersChange").should("have.been.calledOnce");
});
it("does not emit `manual-change` when the user re-selects the same widget value", () => {
const onParametersChange = cy.spy().as("onParametersChange");
cy.get<string>("@dashboardId").then((dashboardId) => {
mountSdkContent(
<InteractiveDashboard
dashboardId={dashboardId}
onParametersChange={onParametersChange}
/>,
);
});
cy.wait("@dashcardQuery");
cy.get("@onParametersChange").should("have.been.calledOnce");
// First selection — fires `manual-change`.
getSdkRoot().within(() => {
H.filterWidget().contains(DATE_FILTER.name).click();
});
H.popover().findByText("Previous 30 days").click();
cy.wait("@dashcardQuery");
cy.get("@onParametersChange").should("have.been.calledTwice");
// Re-select the same option — should be a no-op for the listener
// since applied values didn't change.
getSdkRoot().within(() => {
H.filterWidget().contains(DATE_FILTER.name).click();
});
H.popover().findByText("Update filter").click();
cy.wait(500);
cy.get("@onParametersChange").should("have.been.calledTwice");
});
});

View File

@@ -0,0 +1,398 @@
import {
InteractiveQuestion,
type SqlParameterValues,
} from "@metabase/embedding-sdk-react";
import { useState } from "react";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
type NativeQuestionDetails,
createNativeQuestion,
} from "e2e/support/helpers";
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import {
mountInteractiveQuestion,
mountSdkContent,
} from "e2e/support/helpers/embedding-sdk-component-testing";
import { signInAsAdminAndEnableEmbeddingSdk } from "e2e/support/helpers/embedding-sdk-testing";
import { mockAuthProviderAndJwtSignIn } from "e2e/support/helpers/embedding-sdk-testing/embedding-sdk-helpers";
import type { TemplateTags } from "metabase-types/api";
import { createMockParameter } from "metabase-types/api/mocks";
const { H } = cy;
const { PEOPLE } = SAMPLE_DATABASE;
const TEMPLATE_TAGS: TemplateTags = {
state: {
id: "state",
name: "state",
"display-name": "State",
type: "dimension",
"widget-type": "string/=",
dimension: ["field", PEOPLE.STATE, null],
},
city: {
id: "city",
name: "city",
"display-name": "City",
type: "dimension",
"widget-type": "string/=",
dimension: ["field", PEOPLE.CITY, null],
},
source: {
id: "source",
name: "source",
"display-name": "Source",
type: "dimension",
"widget-type": "string/=",
dimension: ["field", PEOPLE.SOURCE, null],
},
};
const PARAMETERS = [
createMockParameter({
id: "state",
name: "State",
slug: "state",
type: "string/=",
target: ["dimension", ["template-tag", "state"]],
}),
createMockParameter({
id: "city",
name: "City",
slug: "city",
type: "string/=",
target: ["dimension", ["template-tag", "city"]],
}),
createMockParameter({
id: "source",
name: "Source",
slug: "source",
type: "string/=",
target: ["dimension", ["template-tag", "source"]],
}),
];
const NATIVE_QUESTION: NativeQuestionDetails = {
name: "Controlled SQL parameters native question",
native: {
query:
"SELECT * FROM PEOPLE WHERE {{state}} [[ AND {{city}} ]] [[ AND {{source}} ]]",
"template-tags": TEMPLATE_TAGS,
},
parameters: PARAMETERS,
};
const setup = () => {
signInAsAdminAndEnableEmbeddingSdk();
createNativeQuestion(NATIVE_QUESTION, { wrapId: true });
cy.signOut();
mockAuthProviderAndJwtSignIn();
};
const childrenLayout = (
<>
<InteractiveQuestion.SqlParametersList />
<InteractiveQuestion.QuestionVisualization />
</>
);
const findParameterWidget = (name: string) =>
getSdkRoot()
.findAllByTestId("parameter-widget")
.filter(`:contains("${name}")`);
describe("scenarios > embedding-sdk > sdk-question > controlled SQL parameters", () => {
beforeEach(setup);
it("applies values pushed via the `sqlParameters` prop to the widgets and the table", () => {
mountInteractiveQuestion({
sqlParameters: { state: "AR" },
children: childrenLayout,
});
cy.wait("@cardQuery");
findParameterWidget("State").should("contain.text", "AR");
H.ensureParameterColumnValue({
columnName: "STATE",
columnValue: "AR",
});
});
it("re-runs the query when the `sqlParameters` prop reference changes", () => {
const SwitchableQuestion = ({
questionId,
}: {
questionId: number | string;
}) => {
const [sqlParameters, setSqlParameters] = useState<SqlParameterValues>({
state: "AR",
});
return (
<>
<button
type="button"
onClick={() => setSqlParameters({ state: "TX" })}
>
switch to TX
</button>
<InteractiveQuestion
questionId={questionId}
sqlParameters={sqlParameters}
>
{childrenLayout}
</InteractiveQuestion>
</>
);
};
cy.intercept("GET", "/api/card/*").as("getCard");
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
cy.get<number>("@questionId").then((questionId) => {
mountSdkContent(<SwitchableQuestion questionId={questionId} />);
});
cy.wait("@getCard");
cy.wait("@cardQuery");
H.ensureParameterColumnValue({
columnName: "STATE",
columnValue: "AR",
});
cy.contains("button", "switch to TX").click();
cy.wait("@cardQuery");
findParameterWidget("State").should("contain.text", "TX");
H.ensureParameterColumnValue({
columnName: "STATE",
columnValue: "TX",
});
});
it("fires `onSqlParametersChange` with `source: 'initial-state'` exactly once on load", () => {
const onSqlParametersChange = cy.spy().as("onSqlParametersChange");
mountInteractiveQuestion({
sqlParameters: { state: "AR" },
onSqlParametersChange,
children: childrenLayout,
});
cy.wait("@cardQuery");
cy.get("@onSqlParametersChange").should("have.been.calledOnce");
cy.get("@onSqlParametersChange")
.its("firstCall.args.0")
.should((payload) => {
expect(payload.source).to.equal("initial-state");
expect(payload.parameters).to.deep.include({ state: ["AR"] });
expect(payload.defaultParameters).to.be.an("object");
// Question payload spec omits `lastUsedParameters` entirely.
expect(payload).to.not.have.property("lastUsedParameters");
});
});
it("fires `onSqlParametersChange` with `source: 'manual-change'` when the user edits a filter widget", () => {
const onSqlParametersChange = cy.spy().as("onSqlParametersChange");
mountInteractiveQuestion({
sqlParameters: { state: "AR" },
onSqlParametersChange,
children: childrenLayout,
});
cy.wait("@cardQuery");
cy.get("@onSqlParametersChange").should("have.been.calledOnce");
findParameterWidget("State").click();
cy.findByTestId("parameter-value-dropdown").within(() => {
cy.findByText("AR").click();
cy.findByText("NY").click();
cy.findByText("Update filter").click();
});
cy.wait("@cardQuery");
cy.get("@onSqlParametersChange")
.its("lastCall.args.0")
.should((payload) => {
expect(payload.source).to.equal("manual-change");
expect(payload.parameters).to.deep.include({ state: ["NY"] });
});
});
it("fires `onSqlParametersChange` with `source: 'auto-change'` when normalization reshapes the host's push (carries `null` for cleared slugs)", () => {
// The push triggers `auto-change` because scalar values for the
// multi-select widget are wrapped into arrays by the pipeline (here:
// `state: "WA"` => `["WA"]`). The cleared `city` slug rides along —
// cleared slugs come through the payload as `null`, not omitted.
const onSqlParametersChange = cy.spy().as("onSqlParametersChange");
const PushButton = ({ questionId }: { questionId: number | string }) => {
const [sqlParameters, setSqlParameters] = useState<SqlParameterValues>({
state: "AR",
city: "El Paso",
});
return (
<>
<button
type="button"
onClick={() => setSqlParameters({ state: "WA", city: null })}
>
push WA + clear city
</button>
<InteractiveQuestion
questionId={questionId}
sqlParameters={sqlParameters}
onSqlParametersChange={onSqlParametersChange}
>
{childrenLayout}
</InteractiveQuestion>
</>
);
};
cy.intercept("GET", "/api/card/*").as("getCard");
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
cy.get<number>("@questionId").then((questionId) => {
mountSdkContent(<PushButton questionId={questionId} />);
});
cy.wait("@getCard");
cy.wait("@cardQuery");
cy.get("@onSqlParametersChange")
.its("firstCall.args.0")
.should("include", { source: "initial-state" });
cy.contains("button", "push WA + clear city").click();
cy.wait("@cardQuery");
cy.get("@onSqlParametersChange")
.its("lastCall.args.0")
.should((payload) => {
expect(payload.source).to.equal("auto-change");
expect(payload.parameters).to.deep.include({
state: ["WA"],
city: null,
});
});
});
it("fires `onSqlParametersChange` only once on mount when the controlled `sqlParameters` already match the resolved initial state", () => {
const onSqlParametersChange = cy.spy().as("onSqlParametersChange");
mountInteractiveQuestion({
sqlParameters: { state: "AR" },
onSqlParametersChange,
children: childrenLayout,
});
cy.wait("@cardQuery");
cy.wait(500);
cy.get("@onSqlParametersChange").should("have.been.calledOnce");
});
it("does not emit `manual-change` when the user re-selects the same widget value", () => {
const onSqlParametersChange = cy.spy().as("onSqlParametersChange");
mountInteractiveQuestion({
sqlParameters: { state: "AR" },
onSqlParametersChange,
children: childrenLayout,
});
cy.wait("@cardQuery");
cy.get("@onSqlParametersChange").should("have.been.calledOnce");
// First selection — fires `manual-change`.
findParameterWidget("State").click();
cy.findByTestId("parameter-value-dropdown").within(() => {
cy.findByText("AR").click();
cy.findByText("NY").click();
cy.findByText("Update filter").click();
});
cy.wait("@cardQuery");
cy.get("@onSqlParametersChange").should("have.been.calledTwice");
// Re-select the same option — should be a no-op for the listener
// since applied values didn't change.
findParameterWidget("State").click();
cy.findByTestId("parameter-value-dropdown").within(() => {
cy.findByText("Update filter").click();
});
cy.wait(500);
cy.get("@onSqlParametersChange").should("have.been.calledTwice");
});
it("clears a single parameter when its value is set to null", () => {
const ClearableQuestion = ({
questionId,
}: {
questionId: number | string;
}) => {
const [sqlParameters, setSqlParameters] = useState<SqlParameterValues>({
state: "AR",
city: "El Paso",
});
return (
<>
<button
type="button"
onClick={() =>
setSqlParameters((prev) => ({ ...prev, city: null }))
}
>
clear city
</button>
<InteractiveQuestion
questionId={questionId}
sqlParameters={sqlParameters}
>
{childrenLayout}
</InteractiveQuestion>
</>
);
};
cy.intercept("GET", "/api/card/*").as("getCard");
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
cy.get<number>("@questionId").then((questionId) => {
mountSdkContent(<ClearableQuestion questionId={questionId} />);
});
cy.wait("@getCard");
cy.wait("@cardQuery");
findParameterWidget("City").should("contain.text", "El Paso");
cy.contains("button", "clear city").click();
cy.wait("@cardQuery");
findParameterWidget("City").should("not.contain.text", "El Paso");
// The required `state` filter is still applied → STATE column = AR.
H.ensureParameterColumnValue({
columnName: "STATE",
columnValue: "AR",
});
});
});

View File

@@ -4,7 +4,6 @@ const { H } = cy;
const { TablePicker } = H.DataModel;
interface MetadataResponse {
updated_at: string;
data_layer: string;
view_count: number;
}
@@ -45,12 +44,10 @@ describe("Table editing", () => {
TablePicker.getTable("Orders").click();
cy.wait<MetadataResponse>("@metadata").then(({ response }) => {
const updatedAt = response?.body.updated_at ?? "";
const expectedDate = new Date(updatedAt).toLocaleString();
const viewCount = response?.body.view_count ?? 0;
cy.findByLabelText("Name in the database").should("have.text", "ORDERS");
cy.findByLabelText("Last updated at").should("have.text", expectedDate);
cy.findByLabelText("Last updated at").should("exist"); // Testing the actual value is done in TableMetadata.unit.spec.tsx
cy.findByLabelText("View count").should("have.text", viewCount);
cy.findByLabelText("Est. row count").should("not.exist");
cy.findByLabelText("Dependencies").should("have.text", "0");

View File

@@ -7,7 +7,7 @@ describe("admin > MCP apps settings > Cursor install link", () => {
});
it("shows 'Install in Cursor' link only when the Cursor and VS Code switch is enabled", () => {
cy.visit("/admin/metabot");
cy.visit("/admin/metabot/mcp");
H.main().within(() => {
cy.findByText("Supported MCP clients").scrollIntoView();

View File

@@ -441,6 +441,39 @@ describe("scenarios > organization > timelines > question", () => {
H.echartsIcon("star", true).should("be.visible");
});
it("should display the event tooltip when hovering on a stacked chart #74005", () => {
H.createTimelineWithEvents({
timeline: { name: "Releases" },
events: [
{ name: "RC1", timestamp: "2027-10-20T00:00:00Z", icon: "star" },
],
});
H.visitQuestionAdhoc({
dataset_query: {
type: "query",
query: {
"source-table": ORDERS_ID,
aggregation: [["count"], ["sum", ["field", ORDERS.TOTAL, null]]],
breakout: [
["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
],
},
database: SAMPLE_DB_ID,
},
display: "line",
visualization_settings: {
"graph.split_panels": true,
},
});
H.echartsIcon("star").should("be.visible");
H.echartsIcon("star").realHover();
cy.findByTestId("timeline-event-tooltip").within(() => {
cy.findByText("RC1").should("be.visible");
});
});
it("should open the sidebar when clicking an event icon", () => {
H.createTimelineWithEvents({
timeline: { name: "Releases" },

View File

@@ -21,6 +21,15 @@ describe("scenarios > question > saved", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
cy.findAllByText("Orders"); // question and table name appears
// capture the view header height in the saved state to assert it does not
// change after the question transitions to ad-hoc (UXW-3751)
let savedHeaderHeight;
cy.findByTestId("qb-header")
.invoke("outerHeight")
.then((h) => {
savedHeaderHeight = h;
});
// filter to only orders with quantity=100
H.tableHeaderClick("Quantity");
H.popover().findByText("Filter by this column").click();
@@ -35,10 +44,17 @@ describe("scenarios > question > saved", () => {
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("Showing 2 rows"); // query updated
// view header height should be unchanged in the ad-hoc state
cy.findByTestId("qb-header")
.invoke("outerHeight")
.should((h) => {
expect(h).to.equal(savedHeaderHeight);
});
// check that save will give option to replace
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("Save").click();
cy.findByTestId("save-question-modal").within((modal) => {
cy.findByTestId("save-question-modal").within(() => {
cy.findByText('Replace original question, "Orders"');
cy.findByText("Save as new question");
cy.findByText("Cancel").click();

View File

@@ -1,6 +1,7 @@
(ns metabase-enterprise.dependencies.native-validation
(:require
[clojure.string :as str]
[metabase.database-routing.core :as database-routing]
[metabase.driver :as driver]
[metabase.driver.sql :as driver.sql]
[metabase.lib.core :as lib]
@@ -22,9 +23,10 @@
parameter substitution must happen INSIDE the *compile-with-inline-parameters* binding
to produce inline literals instead of ? placeholders."
[query :- ::lib.schema/query]
(let [with-params (lib/add-parameters-for-template-tags query)
compiled (qp.compile/compile-with-inline-parameters with-params)]
(lib/native-query with-params (:query compiled))))
(database-routing/with-database-routing-off
(let [with-params (lib/add-parameters-for-template-tags query)
compiled (qp.compile/compile-with-inline-parameters with-params)]
(lib/native-query with-params (:query compiled)))))
(defn- has-substitutable-template-tags?
"Returns true if the query has any card or table template tags that need

View File

@@ -10,8 +10,12 @@
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.test-metadata :as meta]
[metabase.lib.test-util :as lib.tu]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.util :as u]))
(use-fixtures :once (fixtures/initialize :db))
(defn- fake-query
([mp query]
(fake-query mp query {}))
@@ -609,3 +613,21 @@
:display-name "My Table"
:table-id 1}}))]
(is (empty? (deps.native-validation/validate-native-query driver query))))))
(deftest ^:sequential validate-native-query-with-database-routing-test
(testing "native query validation works on databases with routing enabled (#74084)"
(mt/with-premium-features #{:database-routing}
(let [mp (mt/metadata-provider)
driver (:engine (lib.metadata/database mp))]
(mt/with-temp [:model/DatabaseRouter _ {:database_id (mt/id)
:user_attribute "db_name"}]
(testing "validate-native-query doesn't throw"
;; Without the fix, this would throw:
;; "Anonymous users cannot access a database with routing enabled."
(is (set? (deps.native-validation/validate-native-query
driver
(lib/native-query mp "SELECT * FROM ORDERS")))))
(testing "native-query-deps doesn't throw (backfill path)"
(is (set? (deps.native-validation/native-query-deps
driver
(lib/native-query mp "SELECT * FROM ORDERS"))))))))))

View File

@@ -710,7 +710,7 @@
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
;; I've seen different error messages here, not 100% sure why but the important thing is that this fails
#"(?s)SQL compilation error.*(?:(?:operation cannot be performed)|(?:Object.*does not exist or not authorized))"
#"(?s)(?:SQL compilation error.*(?:(?:operation cannot be performed)|(?:Object.*does not exist or not authorized))|Cannot perform SELECT)"
(mt/run-mbql-query venues
{:aggregation [[:count]]})))

View File

@@ -55,9 +55,12 @@
(qp.store/with-metadata-provider (mt/id)
(sql.qp/->honeysql
(or driver/*driver* :h2)
[:field field-id {::add/source-table (mt/id table-key)
::add/source-alias field-name
::add/desired-alias field-name}])))))
(sql.qp/mbql-clause-with-opts driver/*driver*
:field
{::add/source-table (mt/id table-key)
::add/source-alias field-name
::add/desired-alias field-name}
field-id))))))
(defn- venues-category-mbql-gtap-def []
{:query (mt/mbql-query venues)

View File

@@ -126,6 +126,8 @@ export type {
MetabotErrorMessage,
MetabotMessage,
MetabotUserTextMessage,
ParameterChangeSource,
ParameterChangePayload,
UseMetabotResult,
SdkEntityToken,
SdkCollectionId,
@@ -139,6 +141,8 @@ export type {
SdkQuestionId,
SdkQuestionEntityPublicProps,
SdkQuestionTitleProps,
SqlParameterChangeSource,
SqlParameterChangePayload,
SdkUserId,
SqlParameterValues,
} from "embedding-sdk-bundle/types";

View File

@@ -1,4 +1,4 @@
import { t } from "ttag";
import { c, t } from "ttag";
import {
SettingsPageWrapper,
@@ -49,7 +49,9 @@ export function WhiteLabelBrandingSettingsPage() {
<SettingsSection>
<AdminSettingInput
name="loading-message"
title={t`Loading message`}
title={c(
"Label for a setting that selects the message shown to users while Metabase is loading",
).t`Loading message`}
inputType="select"
options={getLoadingMessageOptions()}
/>

View File

@@ -40,6 +40,7 @@ describe("branding settings", () => {
"Logo",
"Font",
"Favicon",
"Loading message",
];
it.each(brandingSettings)("renders %s", async (setting) => {

View File

@@ -75,6 +75,7 @@ const elements = [
createElement({ type: "shared", name: "hooks", enforceOutgoing: true }),
createElement({ type: "shared", name: "i18n", enforceOutgoing: true }),
createElement({ type: "shared", name: "metadata", enforceOutgoing: true }),
createElement({ type: "shared", name: "metrics", enforceOutgoing: true }),
createElement({
type: "shared",
name: "metrics-viewer",

View File

@@ -14,7 +14,10 @@ import { SdkQuestionAlertListModal } from "embedding-sdk-bundle/components/priva
import { QuestionAlertModalProvider } from "embedding-sdk-bundle/components/private/notifications/context/QuestionAlertModalProvider";
import { useExtractResourceIdFromJwtToken } from "embedding-sdk-bundle/hooks/private/use-extract-resource-id-from-jwt-token";
import { useLoadQuestion } from "embedding-sdk-bundle/hooks/private/use-load-question";
import { useSdkControlledSqlParameters } from "embedding-sdk-bundle/hooks/private/use-sdk-controlled-sql-parameters";
import { useSetupContentTranslations } from "embedding-sdk-bundle/hooks/private/use-setup-content-translations";
import { useWarnConflictingParameterProps } from "embedding-sdk-bundle/hooks/private/use-warn-conflicting-parameter-props";
import { getEffectiveParameterValues } from "embedding-sdk-bundle/lib/controlled-parameters";
import { useSdkDispatch, useSdkSelector } from "embedding-sdk-bundle/store";
import { setInitialGuestToken } from "embedding-sdk-bundle/store/guest-embed";
import {
@@ -71,6 +74,8 @@ export const SdkQuestionProvider = ({
dataPicker,
targetCollection,
initialSqlParameters,
sqlParameters,
onSqlParametersChange,
hiddenParameters,
withDownloads,
withAlerts,
@@ -87,6 +92,11 @@ export const SdkQuestionProvider = ({
const { rawToken: tokenFromStore, error: tokenFetchError } =
useSdkSelector(getSessionTokenState);
const effectiveInitialSqlParameters = getEffectiveParameterValues(
sqlParameters,
initialSqlParameters,
);
// Store token so the refresh handler can check expiry. No need to await — not used here.
useEffect(() => {
if (rawToken && isGuestEmbed) {
@@ -177,10 +187,25 @@ export const SdkQuestionProvider = ({
token,
options,
deserializedCard,
initialSqlParameters,
initialSqlParameters: effectiveInitialSqlParameters,
targetDashboardId,
});
useWarnConflictingParameterProps({
initialParameters: initialSqlParameters,
parameters: sqlParameters,
initialParameterPropName: "initialSqlParameters",
parameterPropName: "sqlParameters",
});
useSdkControlledSqlParameters({
sqlParameters,
onSqlParametersChange,
question,
parameterValues: parameterValues ?? {},
updateParameterValues,
});
const globalPlugins = useSdkSelector(getPlugins);
const plugins: MetabasePluginsConfig = useMemo(() => {

View File

@@ -8,6 +8,7 @@ import type {
LoadSdkQuestionParams,
MetabaseQuestion,
SdkQuestionId,
SqlParameterChangePayload,
SqlParameterValues,
} from "embedding-sdk-bundle/types/question";
import type {
@@ -40,10 +41,37 @@ type SdkQuestionConfig = {
isSaveEnabled?: boolean;
/**
* Initial values for the SQL parameters.
* Initial values for SQL parameters, slug-keyed. Applied once on mount; user widget edits afterwards are not reflected back to the host.
* <br/>
* For each parameter:
* <br/>
* - set to a value: that value is applied.
* <br/>
* - set to `null`: strictly cleared, ignoring the parameter's default.
* <br/>
* - omitted (or set to `undefined`): falls back to the parameter's default (or `null` if it has no default).
**/
initialSqlParameters?: SqlParameterValues;
/**
* Controlled SQL parameter values, slug-keyed. On every render, this object replaces the question's parameter values:
* <br/>
* - a parameter set to a value uses that value.
* <br/>
* - a parameter set to `null` is cleared, even if it has a default.
* <br/>
* - a parameter omitted from the object (or set to `undefined`) uses its default (or `null` if it has no default).
* <br/>
* <br/>
* Pair with `onSqlParametersChange` to stay in sync with user edits.
**/
sqlParameters?: SqlParameterValues;
/**
* Fires on SQL parameters change. The payload's `source` distinguishes the initial state on load (`'initial-state'`), user edits in the UI (`'manual-change'`), and auto-updates (`'auto-change'`).
**/
onSqlParametersChange?: (payload: SqlParameterChangePayload) => void;
/**
* A list of parameters to hide.
**/

View File

@@ -21,6 +21,8 @@ const propsSchema: Yup.SchemaOf<InteractiveQuestionInternalProps> = Yup.object({
dataPicker: Yup.mixed().optional(),
height: Yup.mixed().optional(),
initialSqlParameters: Yup.mixed().optional(),
sqlParameters: Yup.mixed().optional(),
onSqlParametersChange: Yup.mixed().optional(),
hiddenParameters: Yup.mixed().optional(),
isSaveEnabled: Yup.mixed().optional(),
onBeforeSave: Yup.mixed().optional(),

View File

@@ -3,11 +3,15 @@ import { HttpResponse, http } from "msw";
import type { ComponentProps } from "react";
import { CommonSdkStoryWrapper } from "embedding-sdk-bundle/test/CommonSdkStoryWrapper";
import {
ParametersPlayground,
useControlledParametersPlaygroundState,
} from "embedding-sdk-bundle/test/ParametersPlayground";
import {
questionIdArgType,
questionIds,
} from "embedding-sdk-bundle/test/storybook-id-args";
import { Box } from "metabase/ui";
import { Box, Stack } from "metabase/ui";
import {
createMockNativeCard,
createMockNativeDatasetQuery,
@@ -210,3 +214,178 @@ export const CreateQuestion = {
dataPicker: "flat",
},
};
const ControlledSqlParametersPlayground = (args: SdkQuestionComponentProps) => {
const playground = useControlledParametersPlaygroundState({
initialValues: args.sqlParameters ?? args.initialSqlParameters ?? {},
});
return (
<ParametersPlayground
{...playground}
title="Controlled SQL parameters"
description={
<>
Push a SQL parameter value into the question without clicking its
filter UI — mimics a barcode scanner or app-state reflection. The
mocked query handler returns 15 synthetic rows across 5 states / 15
cities / 3 sources (Twitter, Google, Organic) and filters them by
every pushed param, so the visualization reacts to pushes.
</>
}
dashboard={
<Box bg="background-primary" mih="100vh" p="md">
<SdkQuestion
{...args}
sqlParameters={playground.parameters}
onSqlParametersChange={playground.handleParametersChange}
>
<Stack gap="md">
<SdkQuestion.SqlParametersList />
<SdkQuestion.QuestionVisualization />
</Stack>
</SdkQuestion>
</Box>
}
/>
);
};
/**
* Column indices in `nativeQuestionWithParametersData.data.rows`. The
* mock rows are all `state = "AR"`, so we synthesize a more diverse
* dataset below (multiple states / cities / sources) to make the smart
* query handler's filtering visible across all three params.
*/
const NAME_COLUMN_INDEX = 4;
const CITY_COLUMN_INDEX = 5;
const STATE_COLUMN_INDEX = 7;
const SOURCE_COLUMN_INDEX = 8;
type SyntheticPerson = { state: string; city: string; source: string };
const SYNTHETIC_PEOPLE: SyntheticPerson[] = [
{ state: "AR", city: "Little Rock", source: "Twitter" },
{ state: "AR", city: "Fayetteville", source: "Google" },
{ state: "AR", city: "Hot Springs", source: "Organic" },
{ state: "NY", city: "New York", source: "Twitter" },
{ state: "NY", city: "Buffalo", source: "Organic" },
{ state: "NY", city: "Rochester", source: "Google" },
{ state: "CA", city: "Los Angeles", source: "Google" },
{ state: "CA", city: "San Francisco", source: "Twitter" },
{ state: "CA", city: "San Diego", source: "Organic" },
{ state: "TX", city: "Houston", source: "Organic" },
{ state: "TX", city: "Austin", source: "Google" },
{ state: "TX", city: "Dallas", source: "Twitter" },
{ state: "WA", city: "Seattle", source: "Twitter" },
{ state: "WA", city: "Spokane", source: "Organic" },
{ state: "WA", city: "Tacoma", source: "Google" },
];
const SYNTHETIC_PEOPLE_ROWS = SYNTHETIC_PEOPLE.map((person, index) => {
const template = [...nativeQuestionWithParametersData.data.rows[0]];
template[0] = index + 1; // unique ID
template[NAME_COLUMN_INDEX] = `${person.city} resident`;
template[CITY_COLUMN_INDEX] = person.city;
template[STATE_COLUMN_INDEX] = person.state;
template[SOURCE_COLUMN_INDEX] = person.source;
return template;
});
export const ControlledSqlParameters = {
render: (args: SdkQuestionComponentProps) => (
<ControlledSqlParametersPlayground {...args} />
),
args: {
questionId: QUESTION_ID,
isSaveEnabled: false,
title: false,
},
parameters: {
msw: {
handlers: [
http.get(`*/api/card/${QUESTION_ID}`, () =>
HttpResponse.json(
createMockNativeCard({
name: "Controlled SQL parameters demo",
display: "table",
id: QUESTION_ID,
dataset_query: createMockNativeDatasetQuery({
native: createMockNativeQuery({
query:
"SELECT * FROM people WHERE state = {{state}} [[ and city = {{city}} ]] [[ and source = {{source}} ]]",
"template-tags": {
state: {
type: "text",
name: "state",
id: "1",
"display-name": "State",
},
city: {
type: "text",
name: "city",
id: "2",
"display-name": "City",
},
source: {
type: "text",
name: "source",
id: "3",
"display-name": "Source",
},
},
}),
database: SAMPLE_DB_ID,
}),
parameters: [
createMockParameter({ id: "1", slug: "state" }),
createMockParameter({ id: "2", slug: "city" }),
createMockParameter({ id: "3", slug: "source" }),
],
}),
),
),
// Smart query handler: filters rows by the pushed state / city /
// source params so the visualization reacts visibly to every push.
http.post(`*/api/card/${QUESTION_ID}/query`, async ({ request }) => {
const body = (await request.json()) as {
parameters?: Array<{ id: string; value: unknown }>;
};
const paramValueById = new Map<string, string>();
for (const param of body.parameters ?? []) {
if (param.value !== null && param.value !== undefined) {
paramValueById.set(param.id, String(param.value));
}
}
const filterByParam = (
rows: typeof SYNTHETIC_PEOPLE_ROWS,
paramId: string,
columnIndex: number,
) => {
const value = paramValueById.get(paramId);
if (value === undefined) {
return rows;
}
return rows.filter((row) => row[columnIndex] === value);
};
let filteredRows: typeof SYNTHETIC_PEOPLE_ROWS =
SYNTHETIC_PEOPLE_ROWS;
filteredRows = filterByParam(filteredRows, "1", STATE_COLUMN_INDEX);
filteredRows = filterByParam(filteredRows, "2", CITY_COLUMN_INDEX);
filteredRows = filterByParam(filteredRows, "3", SOURCE_COLUMN_INDEX);
return HttpResponse.json({
...nativeQuestionWithParametersData,
data: {
...nativeQuestionWithParametersData.data,
rows: filteredRows,
},
row_count: filteredRows.length,
});
}),
],
},
},
};

View File

@@ -136,6 +136,8 @@ export const _SdkQuestion = ({
dataPicker,
targetCollection,
initialSqlParameters,
sqlParameters,
onSqlParametersChange,
hiddenParameters,
withDownloads = false,
withAlerts = false,
@@ -192,6 +194,8 @@ export const _SdkQuestion = ({
dataPicker={dataPicker}
targetCollection={targetCollection}
initialSqlParameters={initialSqlParameters}
sqlParameters={sqlParameters}
onSqlParametersChange={onSqlParametersChange}
hiddenParameters={hiddenParameters}
withDownloads={withDownloads}
withAlerts={withAlerts}

View File

@@ -12,6 +12,8 @@ const propsSchema: Yup.SchemaOf<StaticQuestionInternalProps> = Yup.object({
className: Yup.mixed().optional(),
height: Yup.mixed().optional(),
initialSqlParameters: Yup.mixed().optional(),
sqlParameters: Yup.mixed().optional(),
onSqlParametersChange: Yup.mixed().optional(),
hiddenParameters: Yup.mixed().optional(),
questionId: Yup.mixed().when(["token", "query"], {
is: (token: unknown, query: unknown) =>

View File

@@ -55,6 +55,8 @@ type StaticQuestionBaseProps = PropsWithChildren<
| "className"
| "style"
| "initialSqlParameters"
| "sqlParameters"
| "onSqlParametersChange"
| "hiddenParameters"
| "withDownloads"
| "withAlerts"
@@ -120,6 +122,8 @@ const StaticQuestionInner = (
className,
style,
initialSqlParameters,
sqlParameters,
onSqlParametersChange,
hiddenParameters,
withDownloads,
withAlerts,
@@ -158,6 +162,8 @@ const StaticQuestionInner = (
getClickActionMode={getClickActionMode}
navigateToNewCard={null}
initialSqlParameters={initialSqlParameters}
sqlParameters={sqlParameters}
onSqlParametersChange={onSqlParametersChange}
hiddenParameters={hiddenParameters}
withDownloads={withDownloads}
withAlerts={withAlerts}

View File

@@ -37,6 +37,8 @@ const propsSchema: Yup.SchemaOf<EditableDashboardProps> = Yup.object({
.noUnknown(),
hiddenParameters: Yup.mixed().optional(),
initialParameters: Yup.mixed().optional(),
parameters: Yup.mixed().optional(),
onParametersChange: Yup.mixed().optional(),
onLoad: Yup.mixed().optional(),
onLoadWithoutCards: Yup.mixed().optional(),
plugins: Yup.object({

View File

@@ -37,6 +37,8 @@ const propsSchema: Yup.SchemaOf<InteractiveDashboardProps> = Yup.object({
.noUnknown(),
hiddenParameters: Yup.mixed().optional(),
initialParameters: Yup.mixed().optional(),
parameters: Yup.mixed().optional(),
onParametersChange: Yup.mixed().optional(),
onLoad: Yup.mixed().optional(),
onLoadWithoutCards: Yup.mixed().optional(),
plugins: Yup.object({

View File

@@ -1,4 +1,8 @@
import { storybookSdkAuthDefaultConfig } from "embedding-sdk-bundle/test/CommonSdkStoryWrapper";
import {
ParametersPlayground,
useControlledParametersPlaygroundState,
} from "embedding-sdk-bundle/test/ParametersPlayground";
import {
dashboardIdArgType,
dashboardIds,
@@ -316,6 +320,41 @@ export const WithInitialParameters = {
},
};
const ControlledParametersPlayground = (args: SdkDashboardProps) => {
const playground = useControlledParametersPlaygroundState({
initialValues: args.parameters ?? args.initialParameters ?? {},
});
return (
<ComponentProvider authConfig={storybookSdkAuthDefaultConfig}>
<ParametersPlayground
{...playground}
title="Controlled parameters"
description="Push a parameter value into the dashboard without clicking its filter UI — mimics a barcode scanner or app-state reflection."
dashboard={
<SdkDashboard
{...args}
parameters={playground.parameters}
onParametersChange={playground.handleParametersChange}
/>
}
/>
</ComponentProvider>
);
};
export const ControlledParameters = {
render: (args: SdkDashboardProps) => (
<ControlledParametersPlayground {...args} />
),
args: {
dashboardId: DASHBOARD_ID,
withTitle: true,
withCardTitle: true,
withDownloads: false,
},
};
export const ExperimentalLayout = {
render(args: SdkDashboardProps) {
return (

View File

@@ -25,11 +25,14 @@ import { SdkQuestion } from "embedding-sdk-bundle/components/public/SdkQuestion/
import { useDashboardLoadHandlers } from "embedding-sdk-bundle/hooks/private/use-dashboard-load-handlers";
import { useExtractResourceIdFromJwtToken } from "embedding-sdk-bundle/hooks/private/use-extract-resource-id-from-jwt-token";
import { useSdkBreadcrumbs } from "embedding-sdk-bundle/hooks/private/use-sdk-breadcrumb";
import { useSdkControlledParameters } from "embedding-sdk-bundle/hooks/private/use-sdk-controlled-parameters";
import {
type SdkDashboardDisplayProps,
useSdkDashboardParams,
} from "embedding-sdk-bundle/hooks/private/use-sdk-dashboard-params";
import { useSetupContentTranslations } from "embedding-sdk-bundle/hooks/private/use-setup-content-translations";
import { useWarnConflictingParameterProps } from "embedding-sdk-bundle/hooks/private/use-warn-conflicting-parameter-props";
import { getEffectiveParameterValues } from "embedding-sdk-bundle/lib/controlled-parameters";
import { useSdkDispatch, useSdkSelector } from "embedding-sdk-bundle/store";
import { setInitialGuestToken } from "embedding-sdk-bundle/store/guest-embed";
import {
@@ -39,6 +42,7 @@ import {
import type { MetabaseQuestion } from "embedding-sdk-bundle/types";
import type {
DashboardEventHandlersProps,
ParameterChangePayload,
SdkDashboardId,
} from "embedding-sdk-bundle/types/dashboard";
import type { MetabasePluginsConfig } from "embedding-sdk-bundle/types/plugins";
@@ -129,13 +133,45 @@ export type SdkDashboardProps = PropsWithChildren<
autoRefreshInterval?: number;
/**
* Query parameters for the dashboard. For a single option, use a `string` value, and use a list of strings for multiple options.
* Initial values for query parameters, slug-keyed. Applied once on mount; user widget edits afterwards are not reflected back to the host.
* <br/>
* For each parameter:
* <br/>
* - set to a value (string for a single option, array of strings for multiple): that value is applied.
* <br/>
* - set to `null`: strictly cleared, ignoring the parameter's default.
* <br/>
* - omitted (or set to `undefined`): falls back to the parameter's default (or `null` if it has no default).
* <br/>
* <br/>
* - Combining {@link SdkDashboardProps.initialParameters | initialParameters} and {@link SdkDashboardDisplayProps.hiddenParameters | hiddenParameters} to filter data on the frontend is a [security risk](https://www.metabase.com/docs/latest/embedding/sdk/authentication.html#security-warning-each-end-user-must-have-their-own-metabase-account).
* <br/>
* - Combining {@link SdkDashboardProps.initialParameters | initialParameters} and {@link SdkDashboardDisplayProps.hiddenParameters | hiddenParameters} to declutter the user interface is fine.
*/
initialParameters?: ParameterValues;
/**
* Controlled parameter values, slug-keyed. On every render, this object replaces the dashboard's parameter values:
* <br/>
* - a parameter set to a value uses that value.
* <br/>
* - a parameter set to `null` is cleared, even if it has a default.
* <br/>
* - a parameter omitted from the object (or set to `undefined`) uses its default (or `null` if it has no default).
* <br/>
* <br/>
* Pair with {@link SdkDashboardProps.onParametersChange | onParametersChange} to stay in sync with user edits.
* <br/>
* - Combining {@link SdkDashboardProps.parameters | parameters} and {@link SdkDashboardDisplayProps.hiddenParameters | hiddenParameters} to filter data on the frontend is a [security risk](https://www.metabase.com/docs/latest/embedding/sdk/authentication.html#security-warning-each-end-user-must-have-their-own-metabase-account).
* <br/>
* - Combining {@link SdkDashboardProps.parameters | parameters} and {@link SdkDashboardDisplayProps.hiddenParameters | hiddenParameters} to declutter the user interface is fine.
*/
parameters?: ParameterValues;
/**
* Fires on parameters change. The payload's `source` distinguishes the initial state on load (`'initial-state'`), user edits in the UI (`'manual-change'`), and auto-updates (`'auto-change'`).
*/
onParametersChange?: (payload: ParameterChangePayload) => void;
} & SdkDashboardDisplayProps &
DashboardEventHandlersProps &
EditableDashboardOwnProps
@@ -170,7 +206,9 @@ const SdkDashboardInner = ({
dashboardId: rawDashboardId,
token: rawToken,
autoRefreshInterval,
initialParameters = {},
initialParameters,
parameters,
onParametersChange,
withTitle = true,
withCardTitle = true,
withDownloads = false,
@@ -200,6 +238,24 @@ const SdkDashboardInner = ({
const isGuestEmbed = useSdkSelector(getIsGuestEmbed);
const dispatch = useSdkDispatch();
const [isFirstRender, setIsFirstRender] = useState(true);
useWarnConflictingParameterProps({
initialParameters,
parameters,
initialParameterPropName: "initialParameters",
parameterPropName: "parameters",
});
const effectiveInitialParameters = getEffectiveParameterValues(
parameters,
initialParameters,
);
useSdkControlledParameters({
parameters,
onParametersChange,
});
const { rawToken: tokenFromStore, error: tokenFetchError } =
useSdkSelector(getSessionTokenState);
@@ -418,7 +474,7 @@ const SdkDashboardInner = ({
ref={dashboardContextProviderRef}
dashboardId={dashboardId}
isGuestEmbed={isGuestEmbed}
parameterQueryParams={initialParameters}
parameterQueryParams={effectiveInitialParameters}
navigateToNewCardFromDashboard={
navigateToNewCardFromDashboard !== undefined
? navigateToNewCardFromDashboard

View File

@@ -20,6 +20,8 @@ const propsSchema: Yup.SchemaOf<StaticDashboardProps> = Yup.object({
.noUnknown(),
hiddenParameters: Yup.mixed().optional(),
initialParameters: Yup.mixed().optional(),
parameters: Yup.mixed().optional(),
onParametersChange: Yup.mixed().optional(),
onLoad: Yup.mixed().optional(),
onLoadWithoutCards: Yup.mixed().optional(),
plugins: Yup.object({

View File

@@ -149,9 +149,12 @@ export function useLoadQuestion({
setIsQuestionLoading(false);
return { ...results, originalQuestion };
} catch (err) {
// Ignore cancelled requests (e.g. when the component unmounts).
// React simulates unmounting on strict mode, therefore "Question not found" will be shown without this.
// Ignore cancelled requests (e.g. when the component unmounts, or when a
// controlled `sqlParameters` push cancels the in-flight load query via the
// shared `deferredRef`). React simulates unmounting on strict mode,
// therefore "Question not found" will be shown without this.
if (isCancelledRequestError(err)) {
setIsQuestionLoading(false);
return {};
}

View File

@@ -0,0 +1,228 @@
import { type MutableRefObject, useCallback, useEffect, useRef } from "react";
import { useLatest } from "react-use";
import { isEqual } from "underscore";
import {
buildControlledParameters,
buildParametersPayload,
} from "embedding-sdk-bundle/lib/controlled-parameters";
import { useSdkDispatch, useSdkSelector } from "embedding-sdk-bundle/store";
import {
type SdkListenerEffect,
type SdkListenerPredicate,
startSdkListening,
} from "embedding-sdk-bundle/store/listener-middleware";
import type { SdkStoreState } from "embedding-sdk-bundle/store/types";
import type { ParameterChangePayload } from "embedding-sdk-bundle/types/dashboard";
import {
REMOVE_PARAMETER,
RESET_PARAMETERS,
SET_PARAMETER_VALUE,
fetchDashboard,
} from "metabase/dashboard/actions";
import {
getDashboardComplete,
getParameterValues,
getParameters,
} from "metabase/dashboard/selectors";
import type { ParameterValues } from "metabase/embedding-sdk/types/dashboard";
import {
SET_PARAMETER_VALUES,
setParameterValues,
} from "metabase/redux/dashboard";
type Options = {
parameters: ParameterValues | null | undefined;
onParametersChange: ((payload: ParameterChangePayload) => void) | undefined;
};
const PARAMETER_CHANGE_ACTION_TYPES = new Set<string>([
SET_PARAMETER_VALUES,
RESET_PARAMETERS,
REMOVE_PARAMETER,
]);
const isParameterSetAction = (action: {
type: string;
payload?: unknown;
}): boolean =>
action.type === SET_PARAMETER_VALUE &&
// We must not fire `change` handler on draft parameter updates
(action.payload as { isDraft?: boolean } | undefined)?.isDraft === false;
/**
* Wires up the controlled `parameters` prop and `onParametersChange` callback
* for `<InteractiveDashboard>`. Splits into a push hook (host => Redux) and an
* observe hook (Redux => host); the shared ref lets the observer attribute a
* change to the host's own push and pick `auto-change` instead of
* `manual-change`.
*/
export const useSdkControlledParameters = ({
parameters,
onParametersChange,
}: Options) => {
const lastParametersPushRef = useRef<ParameterValues | null>(null);
usePushControlledParameters(parameters, lastParametersPushRef);
useObserveAppliedParameters(onParametersChange, lastParametersPushRef);
};
/**
* Pushes the host's controlled `parameters` prop into the dashboard's Redux
* store whenever it changes. Skips the dispatch when the resolved values
* already match what's applied.
*/
const usePushControlledParameters = (
parameters: ParameterValues | null | undefined,
lastParametersPushRef: MutableRefObject<ParameterValues | null>,
) => {
const dispatch = useSdkDispatch();
const parameterDefinitions = useSdkSelector(getParameters);
const appliedParameterValues = useSdkSelector(getParameterValues);
// To skip redundant dispatches when `parameters` reference didn't change
// but the effect re-fired (e.g. `parameterDefinitions` got a new reference from a refetch with the same content)
const lastDispatchedRef = useRef<ParameterValues | undefined>(undefined);
useEffect(() => {
if (parameters === null || parameters === undefined) {
lastDispatchedRef.current = undefined;
return;
}
if (lastDispatchedRef.current === parameters) {
return;
}
if (parameterDefinitions.length === 0) {
return;
}
const next = buildControlledParameters(parameters, parameterDefinitions);
if (!isEqual(next, appliedParameterValues)) {
lastParametersPushRef.current = parameters;
dispatch(setParameterValues(next));
}
lastDispatchedRef.current = parameters;
}, [
parameters,
parameterDefinitions,
appliedParameterValues,
dispatch,
lastParametersPushRef,
]);
};
/**
* Subscribes to dashboard parameter mutations in Redux and fires
* `onParametersChange` with the right `source`: `initial-state` (post-load),
* `auto-change` (host's push reshaped en route — payload differs from
* `lastParametersPushRef`), or `manual-change` (everything else, e.g. user
* widget edit).
*/
const useObserveAppliedParameters = (
onParametersChange: ((payload: ParameterChangePayload) => void) | undefined,
lastParametersPushRef: MutableRefObject<ParameterValues | null>,
) => {
const onParametersChangeRef = useLatest(onParametersChange);
const handleInitialStateAction = useCallback<SdkListenerEffect>(
(_action, listenerApi) => {
const payload = buildDashboardChangePayload(
listenerApi.getState(),
"initial-state",
);
if (payload) {
onParametersChangeRef.current?.(payload);
}
},
[onParametersChangeRef],
);
const isParameterValueChangeAction = useCallback<SdkListenerPredicate>(
(action, currentState, previousState) => {
const isParameterChangeAction =
PARAMETER_CHANGE_ACTION_TYPES.has(action.type) ||
isParameterSetAction(action);
if (!isParameterChangeAction) {
return false;
}
return !isEqual(
getParameterValues(currentState),
getParameterValues(previousState),
);
},
[],
);
const handleManualChangeAction = useCallback<SdkListenerEffect>(
(_action, listenerApi) => {
const state = listenerApi.getState();
const lastParametersPush = lastParametersPushRef.current;
lastParametersPushRef.current = null;
if (lastParametersPush !== null) {
const payload = buildDashboardChangePayload(state, "auto-change");
if (payload && !isEqual(payload.parameters, lastParametersPush)) {
onParametersChangeRef.current?.(payload);
}
return;
} else {
const payload = buildDashboardChangePayload(state, "manual-change");
if (payload) {
onParametersChangeRef.current?.(payload);
}
}
},
[onParametersChangeRef, lastParametersPushRef],
);
useEffect(() => {
const unsubInitial = startSdkListening({
actionCreator: fetchDashboard.fulfilled,
effect: handleInitialStateAction,
});
const unsubManual = startSdkListening({
predicate: isParameterValueChangeAction,
effect: handleManualChangeAction,
});
return () => {
unsubInitial();
unsubManual();
};
}, [
handleInitialStateAction,
isParameterValueChangeAction,
handleManualChangeAction,
]);
};
const buildDashboardChangePayload = (
state: SdkStoreState,
source: ParameterChangePayload["source"],
): ParameterChangePayload | null => {
const dashboard = getDashboardComplete(state);
if (!dashboard) {
return null;
}
return {
source,
...buildParametersPayload(
getParameterValues(state),
getParameters(state),
dashboard.last_used_param_values ?? {},
),
};
};

View File

@@ -0,0 +1,350 @@
import { renderHook } from "@testing-library/react";
import { useSdkDispatch, useSdkSelector } from "embedding-sdk-bundle/store";
import { startSdkListening } from "embedding-sdk-bundle/store/listener-middleware";
import {
getDashboardComplete,
getParameterValues,
getParameters,
} from "metabase/dashboard/selectors";
import type { ParameterValues } from "metabase/embedding-sdk/types/dashboard";
import { setParameterValues } from "metabase/redux/dashboard";
import type { UiParameter } from "metabase-lib/v1/parameters/types";
import type { ParameterValuesMap } from "metabase-types/api";
import { useSdkControlledParameters } from "./use-sdk-controlled-parameters";
jest.mock("embedding-sdk-bundle/store", () => ({
useSdkDispatch: jest.fn(),
useSdkSelector: jest.fn(),
}));
jest.mock("embedding-sdk-bundle/store/listener-middleware", () => ({
startSdkListening: jest.fn(),
}));
jest.mock("metabase/dashboard/selectors", () => ({
getDashboardComplete: jest.fn(),
getParameterValues: jest.fn(),
getParameters: jest.fn(),
}));
const useSdkDispatchMock = useSdkDispatch as unknown as jest.Mock;
const useSdkSelectorMock = useSdkSelector as unknown as jest.Mock;
const startSdkListeningMock = startSdkListening as unknown as jest.Mock;
const getDashboardCompleteMock = getDashboardComplete as unknown as jest.Mock;
const getParameterValuesMock = getParameterValues as unknown as jest.Mock;
const getParametersMock = getParameters as unknown as jest.Mock;
const STATE_PARAM = {
id: "p1",
slug: "state",
name: "State",
type: "string/=",
target: ["variable", ["template-tag", "state"]],
} as unknown as UiParameter;
const CATEGORY_PARAM = {
id: "p2",
slug: "category",
name: "Category",
type: "string/=",
target: ["variable", ["template-tag", "category"]],
} as unknown as UiParameter;
const DEFAULT_DEFINITIONS: UiParameter[] = [STATE_PARAM, CATEGORY_PARAM];
type RenderProps = {
parameters: ParameterValues | null | undefined;
onParametersChange: jest.Mock | undefined;
};
type SetupOptions = {
parameters?: ParameterValues | null;
onParametersChange?: jest.Mock;
parameterDefinitions?: UiParameter[];
appliedParameterValues?: ParameterValuesMap;
};
const setup = (options: SetupOptions = {}) => {
const {
parameters,
onParametersChange,
parameterDefinitions = [],
appliedParameterValues = {},
} = options;
const dispatch = jest.fn();
useSdkDispatchMock.mockReturnValue(dispatch);
const selectorState = { parameterDefinitions, appliedParameterValues };
useSdkSelectorMock.mockImplementation((selector) => {
if (selector === getParameters) {
return selectorState.parameterDefinitions;
}
if (selector === getParameterValues) {
return selectorState.appliedParameterValues;
}
return undefined;
});
// Drive the imported real-selector references used inside the
// listener-middleware effect handlers from the same `selectorState`.
getParametersMock.mockImplementation(
() => selectorState.parameterDefinitions,
);
getParameterValuesMock.mockImplementation(
() => selectorState.appliedParameterValues,
);
getDashboardCompleteMock.mockReturnValue({ last_used_param_values: {} });
const unsubInitial = jest.fn();
const unsubManual = jest.fn();
startSdkListeningMock
.mockReturnValueOnce(unsubInitial)
.mockReturnValueOnce(unsubManual);
const utils = renderHook(
(props: RenderProps) =>
useSdkControlledParameters({
parameters: props.parameters,
onParametersChange: props.onParametersChange,
}),
{ initialProps: { parameters, onParametersChange } as RenderProps },
);
const updateSelectors = (next: Partial<typeof selectorState>) => {
Object.assign(selectorState, next);
};
return {
dispatch,
unsubInitial,
unsubManual,
updateSelectors,
...utils,
};
};
describe("useSdkControlledParameters", () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe("usePushControlledParameters", () => {
it.each([
{ value: undefined, label: "undefined" },
{ value: null, label: "null (JS-level guard for non-React hosts)" },
])("does not dispatch when `parameters` is $label", ({ value }) => {
const { dispatch } = setup({ parameters: value });
expect(dispatch).not.toHaveBeenCalled();
});
it("dispatches setParameterValues once `parameters` and definitions are both ready", () => {
const { dispatch } = setup({
parameters: { state: "NY" },
parameterDefinitions: DEFAULT_DEFINITIONS,
appliedParameterValues: {},
});
expect(dispatch).toHaveBeenCalledTimes(1);
const action = dispatch.mock.calls[0][0];
expect(action.type).toEqual(setParameterValues.toString());
// Full-replace: every defined parameter has an entry.
expect(Object.keys(action.payload)).toEqual(
expect.arrayContaining([STATE_PARAM.id, CATEGORY_PARAM.id]),
);
});
it("does not re-dispatch when applied state already matches the controlled push (avoids spurious manual-change after initial-state)", () => {
const { dispatch, rerender, updateSelectors } = setup({
parameters: { state: "NY" },
parameterDefinitions: DEFAULT_DEFINITIONS,
appliedParameterValues: {},
});
expect(dispatch).toHaveBeenCalledTimes(1);
const firstAction = dispatch.mock.calls[0][0];
// Simulate Redux applying the dispatched values.
updateSelectors({ appliedParameterValues: firstAction.payload });
rerender({ parameters: { state: "NY" }, onParametersChange: undefined });
expect(dispatch).toHaveBeenCalledTimes(1);
});
it("dispatches a deferred initial push once definitions arrive (does not record `lastDispatchedRef` on the empty-defs skip)", () => {
// Keep the same `parameters` reference across renders. If the
// empty-defs early return mistakenly recorded the ref, the second
// run would see `lastDispatchedRef.current === parameters` and
// skip — this test would fail.
const stableParameters = { state: "NY" };
const { dispatch, rerender, updateSelectors } = setup({
parameters: stableParameters,
parameterDefinitions: [],
});
expect(dispatch).not.toHaveBeenCalled();
updateSelectors({ parameterDefinitions: DEFAULT_DEFINITIONS });
rerender({
parameters: stableParameters,
onParametersChange: undefined,
});
expect(dispatch).toHaveBeenCalledTimes(1);
});
it("does not re-dispatch when only `parameterDefinitions` ref changes but `parameters` stays the same", () => {
const stableParameters = { state: "NY" };
const { dispatch, rerender, updateSelectors } = setup({
parameters: stableParameters,
parameterDefinitions: DEFAULT_DEFINITIONS,
appliedParameterValues: {},
});
expect(dispatch).toHaveBeenCalledTimes(1);
updateSelectors({ parameterDefinitions: [...DEFAULT_DEFINITIONS] });
rerender({
parameters: stableParameters,
onParametersChange: undefined,
});
expect(dispatch).toHaveBeenCalledTimes(1);
});
});
describe("useObserveAppliedParameters", () => {
it("subscribes two listeners (initial-state + manual-change) on mount regardless of whether a callback is provided", () => {
setup({ onParametersChange: undefined });
// Subscriptions are unconditional — fire-time presence check
// decides whether to invoke the host callback. Lets a callback
// wired up after mount still receive subsequent events.
expect(startSdkListeningMock).toHaveBeenCalledTimes(2);
});
it("does not fire `onParametersChange` when pushed values are applied unchanged", () => {
const onParametersChange = jest.fn();
const hostInput = { state: ["NY"] };
const { dispatch, rerender, updateSelectors } = setup({
parameters: hostInput,
onParametersChange,
parameterDefinitions: [STATE_PARAM],
appliedParameterValues: {},
});
expect(dispatch).toHaveBeenCalledTimes(1);
const pushed = dispatch.mock.calls[0][0].payload;
const manualSubscription = startSdkListeningMock.mock.calls[1][0];
const effect = manualSubscription.effect;
updateSelectors({ appliedParameterValues: pushed });
rerender({ parameters: hostInput, onParametersChange });
effect({}, { getState: () => ({}) });
expect(onParametersChange).not.toHaveBeenCalled();
});
it("emits `source: 'auto-change'` when pushed values are applied in a different shape (e.g. scalar normalized to array)", () => {
const onParametersChange = jest.fn();
const { dispatch, rerender, updateSelectors } = setup({
parameters: { state: "NY" },
onParametersChange,
parameterDefinitions: [STATE_PARAM],
appliedParameterValues: {},
});
const pushed = dispatch.mock.calls[0][0].payload;
const manualSubscription = startSdkListeningMock.mock.calls[1][0];
const effect = manualSubscription.effect;
updateSelectors({ appliedParameterValues: pushed });
rerender({ parameters: { state: "NY" }, onParametersChange });
effect({}, { getState: () => ({}) });
expect(onParametersChange).toHaveBeenCalledTimes(1);
const payload = onParametersChange.mock.calls[0][0];
expect(payload.source).toEqual("auto-change");
expect(payload.parameters).toEqual({ state: ["NY"] });
});
it("emits `source: 'auto-change'` when full-replace adds slugs the host did not push", () => {
const onParametersChange = jest.fn();
// Two definitions, host pushes only one — `category` ends up
// null-filled in `payload.parameters`, which differs from the
// host's input → host gets an `auto-change` event so they can sync.
const { dispatch, rerender, updateSelectors } = setup({
parameters: { state: ["NY"] },
onParametersChange,
parameterDefinitions: DEFAULT_DEFINITIONS,
appliedParameterValues: {},
});
const pushed = dispatch.mock.calls[0][0].payload;
const manualSubscription = startSdkListeningMock.mock.calls[1][0];
const effect = manualSubscription.effect;
updateSelectors({ appliedParameterValues: pushed });
rerender({ parameters: { state: ["NY"] }, onParametersChange });
effect({}, { getState: () => ({}) });
expect(onParametersChange).toHaveBeenCalledTimes(1);
const payload = onParametersChange.mock.calls[0][0];
expect(payload.source).toEqual("auto-change");
expect(payload.parameters).toMatchObject({
state: ["NY"],
category: null,
});
});
it("emits `manual-change` for user edits after a host push", () => {
const onParametersChange = jest.fn();
const hostInput = { state: ["NY"] };
const { dispatch, rerender, updateSelectors } = setup({
parameters: hostInput,
onParametersChange,
parameterDefinitions: [STATE_PARAM],
appliedParameterValues: {},
});
const pushed = dispatch.mock.calls[0][0].payload;
const manualSubscription = startSdkListeningMock.mock.calls[1][0];
const effect = manualSubscription.effect;
// First fire — push applied unchanged, no callback.
updateSelectors({ appliedParameterValues: pushed });
rerender({ parameters: hostInput, onParametersChange });
effect({}, { getState: () => ({}) });
expect(onParametersChange).not.toHaveBeenCalled();
// Second fire — user widget edit (different value, no pending push).
updateSelectors({ appliedParameterValues: { p1: ["CA"] } });
effect({}, { getState: () => ({}) });
expect(onParametersChange).toHaveBeenCalledTimes(1);
expect(onParametersChange.mock.calls[0][0].source).toEqual(
"manual-change",
);
});
it("does not re-subscribe listeners when `onParametersChange` ref changes between renders (no missed-event window for inline callbacks)", () => {
const initialCallback = jest.fn();
const { rerender } = setup({ onParametersChange: initialCallback });
expect(startSdkListeningMock).toHaveBeenCalledTimes(2);
// Simulate a host re-render passing a fresh inline function.
rerender({
parameters: undefined,
onParametersChange: jest.fn(),
});
// Still 2 — listeners persist across the callback ref change.
expect(startSdkListeningMock).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,202 @@
import { type MutableRefObject, useEffect, useMemo, useRef } from "react";
import { useLatest } from "react-use";
import { isEqual } from "underscore";
import {
buildControlledParameters,
buildParametersPayload,
} from "embedding-sdk-bundle/lib/controlled-parameters";
import type { SqlParameterChangePayload } from "embedding-sdk-bundle/types/question";
import { useSelector } from "metabase/redux";
import { getMetadata } from "metabase/selectors/metadata";
import type Question from "metabase-lib/v1/Question";
import type { UiParameter } from "metabase-lib/v1/parameters/types";
import { getCardUiParameters } from "metabase-lib/v1/parameters/utils/cards";
import type { ParameterValuesMap } from "metabase-types/api";
import type { SqlParameterValues } from "../../types";
type Options = {
sqlParameters: SqlParameterValues | null | undefined;
onSqlParametersChange:
| ((payload: SqlParameterChangePayload) => void)
| undefined;
question: Question | undefined;
parameterValues: ParameterValuesMap;
updateParameterValues: (next: ParameterValuesMap) => void;
};
/**
* Wires up the controlled `sqlParameters` prop and `onSqlParametersChange`
* callback for `<InteractiveQuestion>` (SQL questions only). Splits into a
* push hook (host => query state) and an observe hook (state => host); the
* shared ref lets the observer attribute a change to the host's own push
* and pick `auto-change` instead of `manual-change`.
*/
export const useSdkControlledSqlParameters = ({
sqlParameters,
onSqlParametersChange,
question,
parameterValues,
updateParameterValues,
}: Options) => {
const metadata = useSelector(getMetadata);
const parameterDefinitions = useMemo<UiParameter[]>(
() =>
question
? getCardUiParameters(
question.card(),
metadata,
parameterValues,
question.parameters() || undefined,
)
: [],
[question, metadata, parameterValues],
);
const lastSqlParametersPushRef = useRef<SqlParameterValues | null>(null);
usePushControlledSqlParameters({
sqlParameters,
parameterDefinitions,
appliedParameterValues: parameterValues,
updateParameterValues,
lastSqlParametersPushRef,
});
useObserveAppliedSqlParameters({
question,
parameterDefinitions,
parameterValues,
onSqlParametersChange,
lastSqlParametersPushRef,
});
};
/**
* Pushes the host's controlled `sqlParameters` prop into the question's
* applied values whenever it changes. Skips the dispatch when the resolved
* values already match what's applied.
*/
const usePushControlledSqlParameters = ({
sqlParameters,
parameterDefinitions,
appliedParameterValues,
updateParameterValues,
lastSqlParametersPushRef,
}: {
sqlParameters: SqlParameterValues | null | undefined;
parameterDefinitions: UiParameter[];
appliedParameterValues: ParameterValuesMap;
updateParameterValues: (next: ParameterValuesMap) => void;
lastSqlParametersPushRef: MutableRefObject<SqlParameterValues | null>;
}) => {
// To skip redundant dispatches when `parameters` reference didn't change
// but the effect re-fired (e.g. `parameterDefinitions` got a new reference from a refetch with the same content)
const lastDispatchedRef = useRef<SqlParameterValues | undefined>(undefined);
useEffect(() => {
if (sqlParameters === null || sqlParameters === undefined) {
lastDispatchedRef.current = undefined;
return;
}
if (
lastDispatchedRef.current === sqlParameters ||
parameterDefinitions.length === 0
) {
return;
}
const next = buildControlledParameters(sqlParameters, parameterDefinitions);
if (!isEqual(next, appliedParameterValues)) {
lastSqlParametersPushRef.current = sqlParameters;
updateParameterValues(next);
}
lastDispatchedRef.current = sqlParameters;
}, [
sqlParameters,
parameterDefinitions,
appliedParameterValues,
updateParameterValues,
lastSqlParametersPushRef,
]);
};
/**
* Observes applied SQL parameter values and fires `onSqlParametersChange`
* with the right `source`: `initial-state` (first sighting of a question),
* `auto-change` (host's push reshaped en route — payload differs from
* `lastSqlParametersPushRef`), or `manual-change` (everything else, e.g.
* user widget edit).
*/
const useObserveAppliedSqlParameters = ({
question,
parameterDefinitions,
parameterValues,
onSqlParametersChange,
lastSqlParametersPushRef,
}: {
question: Question | undefined;
parameterDefinitions: UiParameter[];
parameterValues: ParameterValuesMap;
onSqlParametersChange:
| ((payload: SqlParameterChangePayload) => void)
| undefined;
lastSqlParametersPushRef: MutableRefObject<SqlParameterValues | null>;
}) => {
// Tracks question id change, so we can fire event with `source: initial-state`
const emittedQuestionIdRef = useRef<unknown>(undefined);
const emittedValuesRef = useRef<ParameterValuesMap | null>(null);
const callbackRef = useLatest(onSqlParametersChange);
useEffect(() => {
if (!question || parameterDefinitions.length === 0) {
return;
}
const questionId = question.id?.() ?? null;
const isLoadEvent = emittedQuestionIdRef.current !== questionId;
if (!isLoadEvent && isEqual(emittedValuesRef.current, parameterValues)) {
return;
}
emittedQuestionIdRef.current = questionId;
emittedValuesRef.current = parameterValues;
const payload = buildParametersPayload(
parameterValues,
parameterDefinitions,
);
if (isLoadEvent) {
callbackRef.current?.({ source: "initial-state", ...payload });
return;
}
const lastSqlParametersPush = lastSqlParametersPushRef.current;
lastSqlParametersPushRef.current = null;
if (lastSqlParametersPush !== null) {
if (!isEqual(payload.parameters, lastSqlParametersPush)) {
callbackRef.current?.({ source: "auto-change", ...payload });
}
return;
}
callbackRef.current?.({ source: "manual-change", ...payload });
}, [
callbackRef,
question,
parameterDefinitions,
parameterValues,
lastSqlParametersPushRef,
]);
};

View File

@@ -0,0 +1,485 @@
import { renderHook } from "@testing-library/react";
import { useSelector } from "metabase/redux";
import type Question from "metabase-lib/v1/Question";
import type { UiParameter } from "metabase-lib/v1/parameters/types";
import { getCardUiParameters } from "metabase-lib/v1/parameters/utils/cards";
import type { ParameterValuesMap } from "metabase-types/api";
import type { SqlParameterValues } from "../../types";
import { useSdkControlledSqlParameters } from "./use-sdk-controlled-sql-parameters";
jest.mock("metabase/redux", () => ({
useSelector: jest.fn(),
}));
jest.mock("metabase/selectors/metadata", () => ({
getMetadata: jest.fn(),
}));
jest.mock("metabase-lib/v1/parameters/utils/cards", () => ({
getCardUiParameters: jest.fn(),
}));
const useSelectorMock = useSelector as unknown as jest.Mock;
const getCardUiParametersMock = getCardUiParameters as unknown as jest.Mock;
const STATE_PARAM = {
id: "p1",
slug: "state",
name: "State",
type: "string/=",
target: ["variable", ["template-tag", "state"]],
} as unknown as UiParameter;
const CITY_PARAM = {
id: "p2",
slug: "city",
name: "City",
type: "string/=",
target: ["variable", ["template-tag", "city"]],
} as unknown as UiParameter;
const DEFAULT_DEFINITIONS: UiParameter[] = [STATE_PARAM, CITY_PARAM];
const makeQuestion = (id: number): Question =>
({
id: () => id,
card: () => ({}) as unknown,
parameters: () => undefined,
}) as unknown as Question;
type SetupOptions = {
sqlParameters?: SqlParameterValues | null;
onSqlParametersChange?: jest.Mock;
question?: Question;
parameterValues?: ParameterValuesMap;
parameterDefinitions?: UiParameter[];
};
const setup = (options: SetupOptions = {}) => {
const {
sqlParameters,
onSqlParametersChange,
question = makeQuestion(1),
parameterValues = {},
parameterDefinitions = DEFAULT_DEFINITIONS,
} = options;
useSelectorMock.mockReturnValue({});
getCardUiParametersMock.mockReturnValue(parameterDefinitions);
const updateParameterValues = jest.fn();
const utils = renderHook(
(props: {
sqlParameters: SqlParameterValues | null | undefined;
onSqlParametersChange?: jest.Mock;
question: Question | undefined;
parameterValues: ParameterValuesMap;
}) =>
useSdkControlledSqlParameters({
sqlParameters: props.sqlParameters,
onSqlParametersChange: props.onSqlParametersChange,
question: props.question,
parameterValues: props.parameterValues,
updateParameterValues,
}),
{
initialProps: {
sqlParameters,
onSqlParametersChange,
question,
parameterValues,
},
},
);
return { updateParameterValues, ...utils };
};
describe("useSdkControlledSqlParameters", () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe("usePushControlled", () => {
it.each([
{ value: undefined, label: "undefined" },
{ value: null, label: "null (JS-level guard for non-React hosts)" },
])("does not push when `sqlParameters` is $label", ({ value }) => {
const { updateParameterValues } = setup({ sqlParameters: value });
expect(updateParameterValues).not.toHaveBeenCalled();
});
it("pushes once `sqlParameters` and definitions are both ready", () => {
const { updateParameterValues } = setup({
sqlParameters: { state: "NY" },
parameterValues: {},
});
expect(updateParameterValues).toHaveBeenCalledTimes(1);
const next = updateParameterValues.mock.calls[0][0];
// Full-replace: every defined parameter has an entry.
expect(Object.keys(next)).toEqual(
expect.arrayContaining([STATE_PARAM.id, CITY_PARAM.id]),
);
});
it("does not re-push when applied state already matches the controlled push (avoids spurious manual-change after initial-state)", () => {
const { updateParameterValues, rerender } = setup({
sqlParameters: { state: "NY" },
parameterValues: {},
});
expect(updateParameterValues).toHaveBeenCalledTimes(1);
const firstNext = updateParameterValues.mock.calls[0][0];
rerender({
sqlParameters: { state: "NY" },
onSqlParametersChange: undefined,
question: makeQuestion(1),
parameterValues: firstNext,
});
expect(updateParameterValues).toHaveBeenCalledTimes(1);
});
it("does not push when only the `question` ref changes but `sqlParameters` stays the same (`lastDispatchedRef` short-circuits)", () => {
const stableSqlParameters = { state: "NY" };
const { updateParameterValues, rerender } = setup({
sqlParameters: stableSqlParameters,
parameterValues: {},
});
expect(updateParameterValues).toHaveBeenCalledTimes(1);
// New `Question` object with the same id (after mergeQuestionState)
rerender({
sqlParameters: stableSqlParameters,
onSqlParametersChange: undefined,
question: makeQuestion(1),
parameterValues: {},
});
expect(updateParameterValues).toHaveBeenCalledTimes(1);
});
it("pushes once definitions arrive after an empty-defs mount (does not record `lastDispatchedRef` on the early-return skip)", () => {
const stableSqlParameters = { state: "NY" };
const { updateParameterValues, rerender } = setup({
sqlParameters: stableSqlParameters,
parameterDefinitions: [],
parameterValues: {},
});
expect(updateParameterValues).not.toHaveBeenCalled();
getCardUiParametersMock.mockReturnValue(DEFAULT_DEFINITIONS);
rerender({
sqlParameters: stableSqlParameters,
onSqlParametersChange: undefined,
question: makeQuestion(1),
parameterValues: {},
});
expect(updateParameterValues).toHaveBeenCalledTimes(1);
});
});
describe("useObserveAppliedSqlParameters", () => {
it("emits `source: 'initial-state'` once on first sighting of a question", () => {
const onSqlParametersChange = jest.fn();
setup({
sqlParameters: undefined,
onSqlParametersChange,
parameterValues: {},
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(1);
const payload = onSqlParametersChange.mock.calls[0][0];
expect(payload.source).toEqual("initial-state");
// Question payload spec omits `lastUsedParameters` entirely.
expect(payload).not.toHaveProperty("lastUsedParameters");
expect(payload).toHaveProperty("defaultParameters");
});
it("does not re-emit `initial-state` when the question reference changes but the id stays the same (`emittedQuestionIdRef` short-circuits)", () => {
const onSqlParametersChange = jest.fn();
const { rerender } = setup({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: {},
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(1);
rerender({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: {},
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(1);
});
it("emits `source: 'initial-state'` again when the question id changes (navigateToNewCard) — `emittedQuestionIdRef` no longer matches", () => {
const onSqlParametersChange = jest.fn();
const { rerender } = setup({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: {},
});
expect(onSqlParametersChange.mock.calls[0][0].source).toEqual(
"initial-state",
);
rerender({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(2),
parameterValues: {},
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(2);
expect(onSqlParametersChange.mock.calls[1][0].source).toEqual(
"initial-state",
);
});
it("emits `source: 'manual-change'` when applied values change after the initial load", () => {
const onSqlParametersChange = jest.fn();
const { rerender } = setup({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: {},
});
expect(onSqlParametersChange.mock.calls[0][0].source).toEqual(
"initial-state",
);
rerender({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: { [STATE_PARAM.id]: ["NY"] },
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(2);
expect(onSqlParametersChange.mock.calls[1][0].source).toEqual(
"manual-change",
);
});
it("does not re-emit when applied values are deeply equal — `emittedValuesRef` deep-compare suppresses re-select-same-option", () => {
const onSqlParametersChange = jest.fn();
const initialValues: ParameterValuesMap = { [STATE_PARAM.id]: ["NY"] };
const { rerender } = setup({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: initialValues,
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(1);
// New `parameterValues` reference but same content.
rerender({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: { [STATE_PARAM.id]: ["NY"] },
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(1);
});
it("uses `emittedValuesRef` (not the previous prop) for dedup — emit-skip-emit pattern across alternating values", () => {
const onSqlParametersChange = jest.fn();
const valuesA: ParameterValuesMap = { [STATE_PARAM.id]: ["NY"] };
const valuesB: ParameterValuesMap = { [STATE_PARAM.id]: ["CA"] };
const { rerender } = setup({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: valuesA,
});
// initial-state for A.
expect(onSqlParametersChange).toHaveBeenCalledTimes(1);
// A => B: manual-change.
rerender({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: valuesB,
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(2);
expect(onSqlParametersChange.mock.calls[1][0].source).toEqual(
"manual-change",
);
// B => B (fresh ref, same content): skip.
rerender({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: { [STATE_PARAM.id]: ["CA"] },
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(2);
// B => A: manual-change.
rerender({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: { [STATE_PARAM.id]: ["NY"] },
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(3);
expect(onSqlParametersChange.mock.calls[2][0].source).toEqual(
"manual-change",
);
});
it("does not fire `onSqlParametersChange` when pushed values are applied unchanged", () => {
const onSqlParametersChange = jest.fn();
const inputParameters = { state: ["NY"] };
const { updateParameterValues, rerender } = setup({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: {},
parameterDefinitions: [STATE_PARAM],
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(1);
expect(onSqlParametersChange.mock.calls[0][0].source).toEqual(
"initial-state",
);
getCardUiParametersMock.mockReturnValue([STATE_PARAM]);
rerender({
sqlParameters: inputParameters,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: {},
});
const pushed = updateParameterValues.mock.calls[0][0];
rerender({
sqlParameters: inputParameters,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: pushed,
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(1);
});
it("emits `source: 'auto-change'` when pushed values are applied in a different shape (e.g. scalar normalized to array)", () => {
const onSqlParametersChange = jest.fn();
const { updateParameterValues, rerender } = setup({
sqlParameters: undefined,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: {},
parameterDefinitions: [STATE_PARAM],
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(1);
getCardUiParametersMock.mockReturnValue([STATE_PARAM]);
rerender({
sqlParameters: { state: "NY" },
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: {},
});
const pushed = updateParameterValues.mock.calls[0][0];
rerender({
sqlParameters: { state: "NY" },
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: pushed,
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(2);
const payload = onSqlParametersChange.mock.calls[1][0];
expect(payload.source).toEqual("auto-change");
expect(payload.parameters).toEqual({ state: ["NY"] });
});
it("emits `manual-change` for user edits after a host push", () => {
const onSqlParametersChange = jest.fn();
const inputParameters = { state: ["NY"] };
const { updateParameterValues, rerender } = setup({
sqlParameters: inputParameters,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: {},
parameterDefinitions: [STATE_PARAM],
});
const pushed = updateParameterValues.mock.calls[0][0];
rerender({
sqlParameters: inputParameters,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: pushed,
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(1);
expect(onSqlParametersChange.mock.calls[0][0].source).toEqual(
"initial-state",
);
// User edits a widget — different value than what the host pushed.
rerender({
sqlParameters: inputParameters,
onSqlParametersChange,
question: makeQuestion(1),
parameterValues: { ...pushed, [STATE_PARAM.id]: ["CA"] },
});
expect(onSqlParametersChange).toHaveBeenCalledTimes(2);
expect(onSqlParametersChange.mock.calls[1][0].source).toEqual(
"manual-change",
);
});
it("invokes the latest `onSqlParametersChange` ref even if the host swapped it after mount (callback ref isolation)", () => {
const firstCallback = jest.fn();
const secondCallback = jest.fn();
const { rerender } = setup({
sqlParameters: undefined,
onSqlParametersChange: firstCallback,
question: makeQuestion(1),
parameterValues: {},
});
// initial-state goes to the first callback.
expect(firstCallback).toHaveBeenCalledTimes(1);
expect(secondCallback).not.toHaveBeenCalled();
// Host swaps the callback ref + values change → manual-change
// must fire on the *latest* callback, not the stale one.
rerender({
sqlParameters: undefined,
onSqlParametersChange: secondCallback,
question: makeQuestion(1),
parameterValues: { [STATE_PARAM.id]: ["NY"] },
});
expect(firstCallback).toHaveBeenCalledTimes(1);
expect(secondCallback).toHaveBeenCalledTimes(1);
expect(secondCallback.mock.calls[0][0].source).toEqual("manual-change");
});
});
});

View File

@@ -0,0 +1,33 @@
import { useEffect } from "react";
type Options = {
initialParameters: unknown;
parameters: unknown;
} & (
| {
initialParameterPropName: "initialParameters";
parameterPropName: "parameters";
}
| {
initialParameterPropName: "initialSqlParameters";
parameterPropName: "sqlParameters";
}
);
export const useWarnConflictingParameterProps = ({
initialParameters,
parameters,
initialParameterPropName,
parameterPropName,
}: Options) => {
const hasConflict =
initialParameters !== undefined && parameters !== undefined;
useEffect(() => {
if (hasConflict) {
console.warn(
`\`${initialParameterPropName}\` is ignored when \`${parameterPropName}\` is set. Pass only one.`,
);
}
}, [hasConflict, initialParameterPropName, parameterPropName]);
};

View File

@@ -0,0 +1,92 @@
import type { ParameterValues } from "metabase/embedding-sdk/types/dashboard";
import { getParameterValuesByIdFromQueryParams } from "metabase/parameters/utils/parameter-parsing";
import type { UiParameter } from "metabase-lib/v1/parameters/types";
import {
getDefaultValuePopulatedParameters,
getParameterValuesBySlug,
} from "metabase-lib/v1/parameters/utils/parameter-values";
import type { ParameterValuesMap } from "metabase-types/api";
/**
* Translates an explicit `null` to `""` so the URL-querystring parser treats
* it as a strict clear (ignore the parameter's default). Missing slugs fall
* back to `parameter.default ?? null`.
*/
export const mapExplicitNullToEmpty = (
values: ParameterValues,
): ParameterValues => {
const result: ParameterValues = {};
for (const [slug, value] of Object.entries(values)) {
result[slug] = value === null ? "" : value;
}
return result;
};
/**
* Builds the id-keyed parameter values dispatched by the controlled push from
* the host's slug-keyed `parameters`/`sqlParameters` prop. Missing slugs fall
* back to `parameter.default ?? null`.
*/
export const buildControlledParameters = (
parameters: ParameterValues,
parameterDefinitions: UiParameter[],
): ParameterValuesMap =>
getParameterValuesByIdFromQueryParams(
parameterDefinitions,
mapExplicitNullToEmpty(parameters),
);
/**
* Picks which set of seed values to use on mount when both the
* controlled (`parameters`) and uncontrolled (`initialParameters`) props are
* available. Controlled wins when set; otherwise falls back to initial.
* Routes both through `mapExplicitNullToEmpty` so explicit `null` consistently
* means "strictly clear this slug" (ignore the parameter's default and last-used).
*/
export const getEffectiveParameterValues = (
controlledParameters: ParameterValues | null | undefined,
initialParameters: ParameterValues | undefined,
): ParameterValues =>
mapExplicitNullToEmpty(controlledParameters ?? initialParameters ?? {});
/**
* Builds the slug-keyed payload delivered to `onParametersChange` /
* `onSqlParametersChange`: the currently applied values, the defaults from
* the parameter definitions, and (dashboards only) the last-used values for
* this user. The `lastUsedParameters` field is included only when its
* argument is provided.
*/
export function buildParametersPayload(
applied: ParameterValuesMap,
parameterDefinitions: UiParameter[],
): { parameters: ParameterValues; defaultParameters: ParameterValues };
export function buildParametersPayload(
applied: ParameterValuesMap,
parameterDefinitions: UiParameter[],
lastUsedParameterValues: ParameterValuesMap,
): {
parameters: ParameterValues;
defaultParameters: ParameterValues;
lastUsedParameters: ParameterValues;
};
export function buildParametersPayload(
applied: ParameterValuesMap,
parameterDefinitions: UiParameter[],
lastUsedParameterValues?: ParameterValuesMap,
) {
return {
parameters: getParameterValuesBySlug(parameterDefinitions, applied),
defaultParameters: getParameterValuesBySlug(
getDefaultValuePopulatedParameters(parameterDefinitions, {}),
{},
),
...(lastUsedParameterValues && {
lastUsedParameters: getParameterValuesBySlug(
parameterDefinitions,
lastUsedParameterValues,
),
}),
};
}

View File

@@ -0,0 +1,219 @@
import type { UiParameter } from "metabase-lib/v1/parameters/types";
import {
buildControlledParameters,
buildParametersPayload,
getEffectiveParameterValues,
mapExplicitNullToEmpty,
} from "./controlled-parameters";
const STATE_PARAM = {
id: "p1",
slug: "state",
name: "State",
type: "string/=",
default: "AR",
target: ["variable", ["template-tag", "state"]],
} as unknown as UiParameter;
const CATEGORY_PARAM = {
id: "p2",
slug: "category",
name: "Category",
type: "string/=",
// No default — falls back to `null`.
target: ["variable", ["template-tag", "category"]],
} as unknown as UiParameter;
const DEFAULT_DEFINITIONS: UiParameter[] = [STATE_PARAM, CATEGORY_PARAM];
describe("mapExplicitNullToEmpty", () => {
it('maps explicit `null` values to `""`', () => {
expect(mapExplicitNullToEmpty({ state: null, category: null })).toEqual({
state: "",
category: "",
});
});
it("preserves non-null values as-is", () => {
expect(
mapExplicitNullToEmpty({ state: "NY", count: 3, flags: ["a", "b"] }),
).toEqual({ state: "NY", count: 3, flags: ["a", "b"] });
});
it("returns an empty object for an empty input", () => {
expect(mapExplicitNullToEmpty({})).toEqual({});
});
it('maps `null` to `""` and preserves non-null values when both appear in the same object', () => {
expect(
mapExplicitNullToEmpty({
state: null,
category: "Foo",
count: 3,
flags: ["a", "b"],
}),
).toEqual({
state: "",
category: "Foo",
count: 3,
flags: ["a", "b"],
});
});
});
describe("buildControlledParameters", () => {
it("maps slug-keyed values to id-keyed and parses each value", () => {
const result = buildControlledParameters(
{ state: "NY" },
DEFAULT_DEFINITIONS,
);
expect(result).toEqual({
[STATE_PARAM.id]: ["NY"],
[CATEGORY_PARAM.id]: null,
});
});
it("falls back to `parameter.default` for missing slugs", () => {
const result = buildControlledParameters(
{ state: "NY" },
DEFAULT_DEFINITIONS,
);
expect(result).toEqual({
[STATE_PARAM.id]: ["NY"],
[CATEGORY_PARAM.id]: null,
});
});
it("treats explicit `null` as a strict clear and ignores `parameter.default`", () => {
const result = buildControlledParameters(
{ state: null },
DEFAULT_DEFINITIONS,
);
expect(result).toEqual({
[STATE_PARAM.id]: null,
[CATEGORY_PARAM.id]: null,
});
});
it("returns `parameter.default ?? null` for every slug when given an empty object", () => {
const result = buildControlledParameters({}, DEFAULT_DEFINITIONS);
expect(result).toEqual({
[STATE_PARAM.id]: "AR",
[CATEGORY_PARAM.id]: null,
});
});
describe("controlled-parameters value normalization round-trip", () => {
it("wraps a single-value string push into an array (matches widget storage shape)", () => {
// Host pushes a bare string for an array-like parameter.
const pushed = buildControlledParameters(
{ state: "Gizmo" },
DEFAULT_DEFINITIONS,
);
expect(pushed).toEqual({
[STATE_PARAM.id]: ["Gizmo"],
[CATEGORY_PARAM.id]: null,
});
});
it("preserves an array push unchanged (no double-wrap)", () => {
const pushed = buildControlledParameters(
{ state: ["Gizmo", "Widget"] },
DEFAULT_DEFINITIONS,
);
expect(pushed).toEqual({
[STATE_PARAM.id]: ["Gizmo", "Widget"],
[CATEGORY_PARAM.id]: null,
});
});
it("emits the normalized array shape back to the host", () => {
const applied = buildControlledParameters(
{ state: "Gizmo" },
DEFAULT_DEFINITIONS,
);
const payload = buildParametersPayload(applied, DEFAULT_DEFINITIONS);
// Host gets back the normalized array, not its original string.
expect(payload.parameters).toEqual({
state: ["Gizmo"],
category: null,
});
});
});
});
describe("getEffectiveParameterValues", () => {
it('translates explicit `null` to `""` for the controlled prop (strict clear)', () => {
expect(
getEffectiveParameterValues({ state: null, category: "Foo" }, undefined),
).toEqual({ state: "", category: "Foo" });
});
it('translates explicit `null` to `""` for `initialParameters` too (consistent strict-clear semantic across both props)', () => {
expect(
getEffectiveParameterValues(undefined, { state: null, category: "Foo" }),
).toEqual({ state: "", category: "Foo" });
});
it("falls back to `initialParameters` when the controlled prop is undefined", () => {
expect(getEffectiveParameterValues(undefined, { state: "NY" })).toEqual({
state: "NY",
});
});
it("falls back to `initialParameters` when the controlled prop is `null` (JS-level guard)", () => {
expect(getEffectiveParameterValues(null, { state: "NY" })).toEqual({
state: "NY",
});
});
it("returns `{}` when both inputs are missing", () => {
expect(getEffectiveParameterValues(undefined, undefined)).toEqual({});
});
});
describe("buildParametersPayload", () => {
it("returns slug-keyed `parameters` from the id-keyed `applied` map", () => {
const result = buildParametersPayload(
{ [STATE_PARAM.id]: ["NY"] },
DEFAULT_DEFINITIONS,
);
expect(result.parameters).toEqual({ state: ["NY"], category: null });
});
it("returns slug-keyed `defaultParameters` derived from each parameter's `default` field", () => {
const result = buildParametersPayload({}, DEFAULT_DEFINITIONS);
expect(result.defaultParameters).toEqual({
state: "AR",
category: null,
});
});
it("omits `lastUsedParameters` when called with two args", () => {
const result = buildParametersPayload({}, DEFAULT_DEFINITIONS);
expect(result).not.toHaveProperty("lastUsedParameters");
});
it("includes slug-keyed `lastUsedParameters` when called with three args", () => {
const result = buildParametersPayload({}, DEFAULT_DEFINITIONS, {
[STATE_PARAM.id]: ["TX"],
});
expect(result.lastUsedParameters).toEqual({
state: ["TX"],
category: null,
});
});
});

View File

@@ -9,6 +9,7 @@ import { DEFAULT_EMBEDDING_ENTITY_TYPES } from "metabase/redux/embedding-data-pi
import { getStore } from "metabase/store";
import { reducer as visualizer } from "metabase/visualizer/visualizer.slice";
import { sdkListenerMiddleware } from "./listener-middleware";
import { sdk } from "./reducer";
import type { SdkDispatch, SdkStore } from "./types";
@@ -21,16 +22,21 @@ export const sdkReducers = {
} as unknown as Record<string, Reducer>;
export const getSdkStore = () =>
getStore(sdkReducers, null, {
embed: {
options: {
entity_types: DEFAULT_EMBEDDING_ENTITY_TYPES,
getStore(
sdkReducers,
null,
{
embed: {
options: {
entity_types: DEFAULT_EMBEDDING_ENTITY_TYPES,
},
},
app: {
isDndAvailable: false,
},
},
app: {
isDndAvailable: false,
},
}) as unknown as SdkStore;
[sdkListenerMiddleware.middleware],
) as unknown as SdkStore;
export const useSdkDispatch = () => {
useCheckSdkReduxContext();

View File

@@ -0,0 +1,29 @@
import {
type TypedStartListening,
createListenerMiddleware,
} from "@reduxjs/toolkit";
import type { SdkDispatch, SdkStoreState } from "./types";
type SdkStartListening = TypedStartListening<SdkStoreState, SdkDispatch>;
type SdkListenerOptions = Parameters<typeof startSdkListening>[0];
/** Effect callback: `(action, listenerApi) => void`, runs when the listener triggers. */
export type SdkListenerEffect = SdkListenerOptions extends { effect: infer E }
? E & ((...args: any[]) => void)
: never;
/** Predicate callback: `(action, currentState, previousState) => boolean`, gates the effect. */
export type SdkListenerPredicate = NonNullable<
SdkListenerOptions extends { predicate?: infer P } ? P : never
>;
/**
* Used by parameter-change emit pipelines to fire `source: 'initial-state'`
* exactly once per `fetchDashboard.fulfilled`
*/
export const sdkListenerMiddleware = createListenerMiddleware();
export const startSdkListening =
sdkListenerMiddleware.startListening as SdkStartListening;

View File

@@ -0,0 +1,297 @@
/* eslint-disable i18next/no-literal-string -- Storybook */
import { type ReactNode, useState } from "react";
import type {
ParameterChangeSource,
SqlParameterChangeSource,
} from "embedding-sdk-bundle/types";
import type { ParameterValues } from "metabase/embedding-sdk/types/dashboard";
import {
Box,
Button,
Code,
Divider,
Flex,
Group,
Stack,
Text,
TextInput,
Title,
} from "metabase/ui";
export type ParametersPlaygroundProps = {
title: string;
description?: ReactNode;
ready?: boolean;
parameters: ParameterValues;
log: string[];
dashboard: ReactNode;
onSetOne: (slug: string, value: string) => void;
onClearOne: (slug: string) => void;
onClearAll: () => void;
onGetNow?: () => void;
readValue?: ParameterValues | null;
source?: string | null;
defaultParameters?: ParameterValues | null;
lastUsedParameters?: ParameterValues | null;
};
export const ParametersPlayground = ({
title,
description,
ready,
parameters,
log,
dashboard,
onSetOne,
onClearOne,
onClearAll,
onGetNow,
readValue,
source,
defaultParameters,
lastUsedParameters,
}: ParametersPlaygroundProps) => {
const [paramSlug, setParamSlug] = useState("");
const [paramValue, setParamValue] = useState("");
return (
<Flex h="100vh">
<Stack
w={400}
p="md"
gap="md"
style={{ borderRight: "1px solid #e0e0e0", overflowY: "auto" }}
>
<Title order={4}>{title}</Title>
{description && (
<Text size="sm" c="text-secondary">
{description}
</Text>
)}
{ready !== undefined && (
<Text size="xs" c="text-secondary">
ready: <b>{String(ready)}</b>
</Text>
)}
<Stack gap="xs">
<TextInput
label="Parameter slug (or id)"
placeholder="e.g. state"
value={paramSlug}
onChange={(e) => setParamSlug(e.currentTarget.value)}
/>
<TextInput
label="Value"
placeholder="e.g. NY"
value={paramValue}
onChange={(e) => setParamValue(e.currentTarget.value)}
/>
<Group gap="xs">
<Button
size="xs"
onClick={() => paramSlug && onSetOne(paramSlug, paramValue)}
>
Set
</Button>
<Button
size="xs"
variant="default"
onClick={() => paramSlug && onClearOne(paramSlug)}
>
Clear one
</Button>
<Button size="xs" variant="subtle" onClick={onClearAll}>
Clear all
</Button>
{onGetNow && (
<Button size="xs" variant="light" onClick={onGetNow}>
Get now
</Button>
)}
</Group>
</Stack>
<Divider />
<Stack gap={4}>
<Text size="sm" fw={500}>
Parent state
</Text>
<Text size="xs" c="text-secondary">
Host app&apos;s view. Updated eagerly on every push; synced from
every observed change event (including user edits).
</Text>
<Code block>{JSON.stringify(parameters, null, 2)}</Code>
</Stack>
{(source !== undefined ||
defaultParameters !== undefined ||
lastUsedParameters !== undefined) && (
<Stack gap={4}>
<Text size="sm" fw={500}>
Last received payload
</Text>
{source !== undefined && (
<Text size="xs">
source: <b>{source ?? "(none)"}</b>
</Text>
)}
{defaultParameters !== undefined && (
<>
<Text size="xs" fw={500}>
defaultParameters
</Text>
<Code block>
{defaultParameters === null
? "(none)"
: JSON.stringify(defaultParameters, null, 2)}
</Code>
</>
)}
{lastUsedParameters !== undefined && (
<>
<Text size="xs" fw={500}>
lastUsedParameters
</Text>
<Code block>
{lastUsedParameters === null
? "(none)"
: JSON.stringify(lastUsedParameters, null, 2)}
</Code>
</>
)}
</Stack>
)}
{onGetNow && (
<Stack gap={4}>
<Text size="sm" fw={500}>
Last &quot;Get now&quot; snapshot
</Text>
<Text size="xs" c="text-secondary">
Sync read of the controlled property — what the host last pushed.
Should match Parent state shortly after any change.
</Text>
<Code block>
{readValue === null || readValue === undefined
? "(not called)"
: JSON.stringify(readValue, null, 2)}
</Code>
</Stack>
)}
<Divider />
<Stack gap={4}>
<Text size="sm" fw={500}>
Event log
</Text>
<Box
style={{
maxHeight: 260,
overflowY: "auto",
fontFamily: "monospace",
fontSize: 12,
}}
>
{log.length === 0 ? (
<Text size="xs" c="text-secondary">
No events yet.
</Text>
) : (
log.map((line, i) => <div key={i}>{line}</div>)
)}
</Box>
</Stack>
</Stack>
<Box flex={1} style={{ overflow: "hidden" }}>
{dashboard}
</Box>
</Flex>
);
};
export const formatLogEntry = (entry: string) =>
`${new Date().toLocaleTimeString()} — ${entry}`;
type ChangePayload = {
source: ParameterChangeSource | SqlParameterChangeSource;
parameters: ParameterValues;
defaultParameters: ParameterValues;
lastUsedParameters?: ParameterValues;
};
export type UseControlledParametersPlaygroundStateOptions = {
initialValues?: ParameterValues;
onLocalChange?: (next: ParameterValues) => void;
};
export const useControlledParametersPlaygroundState = ({
initialValues = {},
onLocalChange,
}: UseControlledParametersPlaygroundStateOptions = {}) => {
const [parameters, setParameters] = useState<ParameterValues>(initialValues);
const [source, setSource] = useState<string | null>(null);
const [defaultParameters, setDefaultParameters] =
useState<ParameterValues | null>(null);
const [lastUsedParameters, setLastUsedParameters] =
useState<ParameterValues | null>(null);
const [log, setLog] = useState<string[]>([]);
const pushLog = (entry: string) =>
setLog((prev) => [formatLogEntry(entry), ...prev]);
const patchAndLog = (patch: ParameterValues, description: string) => {
setParameters((prev) => {
const next = { ...prev, ...patch };
onLocalChange?.(next);
return next;
});
pushLog(description);
};
const handleParametersChange = (payload: ChangePayload) => {
setParameters(payload.parameters);
setSource(payload.source);
setDefaultParameters(payload.defaultParameters);
setLastUsedParameters(payload.lastUsedParameters ?? null);
pushLog(
`onParametersChange [${payload.source}] ${JSON.stringify(payload.parameters)}`,
);
};
const onSetOne = (slug: string, value: string) =>
patchAndLog({ [slug]: value }, `push ${slug} = ${JSON.stringify(value)}`);
const onClearOne = (slug: string) =>
patchAndLog({ [slug]: null }, `clear ${slug} (push null)`);
const onClearAll = () => {
const knownKeys = Object.keys(parameters);
if (knownKeys.length === 0) {
pushLog("clear all — nothing known yet (wait for onParametersChange)");
return;
}
patchAndLog(
Object.fromEntries(knownKeys.map((key) => [key, null])),
`clear all (push null for ${knownKeys.join(", ")})`,
);
};
return {
parameters,
source,
defaultParameters,
lastUsedParameters,
log,
pushLog,
handleParametersChange,
onSetOne,
onClearOne,
onClearAll,
};
};

View File

@@ -3,6 +3,7 @@ import type {
SdkCollectionId,
} from "embedding-sdk-bundle/types/collection";
import type { CreateDashboardProperties } from "metabase/common/CreateDashboard/CreateDashboardForm";
import type { ParameterValues } from "metabase/embedding-sdk/types/dashboard";
import type { CardDisplayType } from "metabase-types/api";
import type { SdkEntityId, SdkEntityToken } from "./entity";
@@ -87,3 +88,28 @@ export type CreateDashboardValues = Omit<
*/
collectionId: SdkCollectionId;
};
/**
* Source of a parameter-change event:
* - `initial-state` - first applied snapshot, fired once per dashboard load.
* - `manual-change` - user edited parameters in UI.
* - `auto-change` - in the case of auto-updates, e.g. to pass normalized values back to parent.
*
* @category Dashboard
*/
export type ParameterChangeSource =
| "initial-state"
| "manual-change"
| "auto-change";
/**
* Payload passed to `onParametersChange` callback
*
* @category Dashboard
*/
export type ParameterChangePayload = {
source: ParameterChangeSource;
parameters: ParameterValues;
defaultParameters: ParameterValues;
lastUsedParameters: ParameterValues;
};

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import type { ParameterValues } from "metabase/embedding-sdk/types/dashboard";
import type { QueryParams } from "metabase/query_builder/actions";
import type { Deferred } from "metabase/utils/promise";
import type { ObjectId } from "metabase/visualizations/components/ObjectDetail/types";
@@ -142,3 +143,27 @@ export type SqlParameterValues = Record<
| null
| undefined
>;
/**
* Source of a sql-parameter-change event:
* - `initial-state` - first applied state, fired once per question load.
* - `manual-change` - user edited parameters in UI.
* - `auto-change` - in the case of auto-updates, e.g. to pass normalized values back to parent.
*
* @category InteractiveQuestion
*/
export type SqlParameterChangeSource =
| "initial-state"
| "manual-change"
| "auto-change";
/**
* Payload passed to `onSqlParametersChange` callback
*
* @category InteractiveQuestion
*/
export type SqlParameterChangePayload = {
source: SqlParameterChangeSource;
parameters: ParameterValues;
defaultParameters: ParameterValues;
};

View File

@@ -1,36 +0,0 @@
import type { NormalizedMeasure } from "metabase-types/api";
import type Metadata from "./Metadata";
import type Table from "./Table";
interface Measure extends Omit<NormalizedMeasure, "table"> {
table?: Table;
metadata?: Metadata;
}
/**
* @deprecated use RTK Query endpoints and plain api objects from metabase-types/api
*/
class Measure {
private readonly _plainObject: NormalizedMeasure;
constructor(measure: NormalizedMeasure) {
this._plainObject = measure;
Object.assign(this, measure);
}
getPlainObject() {
return this._plainObject;
}
displayName() {
return this.name;
}
isActive() {
return !this.archived;
}
}
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default Measure;

View File

@@ -6,11 +6,12 @@ import type {
DatabaseId,
FieldId,
FieldReference,
Measure,
MeasureId,
Metric,
NativeQuerySnippet,
SchemaId,
SegmentId,
Segment,
SettingKey,
Settings,
TableId,
@@ -20,9 +21,7 @@ import type Question from "../Question";
import type Database from "./Database";
import type Field from "./Field";
import type Measure from "./Measure";
import type Schema from "./Schema";
import type Segment from "./Segment";
import type Table from "./Table";
import { getUniqueFieldId } from "./utils/fields";
import { SAVED_QUESTIONS_VIRTUAL_DB_ID } from "./utils/saved-questions";
@@ -89,20 +88,6 @@ class Metadata {
return Object.values(this.fields);
}
/**
* @deprecated load data via RTK Query - useListSegmentsQuery
*/
segmentsList(): Segment[] {
return Object.values(this.segments);
}
/**
* @deprecated load data via RTK Query - useGetSegmentQuery
*/
segment(segmentId: SegmentId | undefined | null): Segment | null {
return (segmentId != null && this.segments[segmentId]) || null;
}
/**
* @deprecated load data via RTK Query - useListMeasuresQuery
*/

View File

@@ -2,7 +2,6 @@ import { createMockMetadata } from "__support__/metadata";
import {
createMockDatabase,
createMockField,
createMockSegment,
createMockTable,
} from "metabase-types/api/mocks";
@@ -68,17 +67,6 @@ describe("Metadata", () => {
});
});
describe("segmentsList (deprecated)", () => {
it("should return a list of segment objects found on the instance", () => {
const metadata = createMockMetadata({
segments: [createMockSegment({ id: 1 }), createMockSegment({ id: 2 })],
});
const segments = metadata.segmentsList();
expect(segments).toEqual([metadata.segment(1), metadata.segment(2)]);
});
});
describe("`field`", () => {
it("should return null when given a nil fieldId arg", () => {
const metadata = createMockMetadata({});

View File

@@ -1,40 +0,0 @@
import type { Filter, NormalizedSegment } from "metabase-types/api";
import type Metadata from "./Metadata";
import type Table from "./Table";
interface Segment extends Omit<NormalizedSegment, "table"> {
table?: Table;
metadata?: Metadata;
}
/**
* @deprecated use RTK Query endpoints and plain api objects from metabase-types/api
*/
class Segment {
private readonly _plainObject: NormalizedSegment;
constructor(segment: NormalizedSegment) {
this._plainObject = segment;
Object.assign(this, segment);
}
getPlainObject() {
return this._plainObject;
}
displayName() {
return this.name;
}
filterClause(): Filter {
return ["segment", this.id];
}
isActive() {
return !this.archived;
}
}
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default Segment;

View File

@@ -1,75 +0,0 @@
import { createMockMetadata } from "__support__/metadata";
import type { Segment } from "metabase-types/api";
import { createMockSegment } from "metabase-types/api/mocks";
interface SetupOpts {
segment?: Segment;
}
const setup = ({ segment = createMockSegment() }: SetupOpts = {}) => {
const metadata = createMockMetadata({
segments: [segment],
});
const instance = metadata.segment(segment.id);
if (!instance) {
throw new TypeError();
}
return instance;
};
describe("Segment", () => {
describe("instantiation", () => {
it("should create an instance of Segment", () => {
const segment = setup();
expect(segment).toBeDefined();
});
});
describe("displayName", () => {
it("should return the `name` property found on the instance", () => {
const segment = setup({
segment: createMockSegment({
name: "foo",
}),
});
expect(segment.displayName()).toBe("foo");
});
});
describe("filterClause", () => {
it("should return a filter clause", () => {
const segment = setup({
segment: createMockSegment({
id: 123,
}),
});
expect(segment.filterClause()).toEqual(["segment", 123]);
});
});
describe("isActive", () => {
it("should return true if the segment is not archived", () => {
const segment = setup({
segment: createMockSegment({
archived: false,
}),
});
expect(segment.isActive()).toBe(true);
});
it("should return false if the segment is archived", () => {
const segment = setup({
segment: createMockSegment({
archived: true,
}),
});
expect(segment.isActive()).toBe(false);
});
});
});

View File

@@ -2,17 +2,15 @@ import _ from "underscore";
// NOTE: this needs to be imported first due to some cyclical dependency nonsense
import { singularize } from "metabase/utils/formatting";
import type { NormalizedTable } from "metabase-types/api";
import type { Measure, NormalizedTable, Segment } from "metabase-types/api";
import Question from "../Question";
import type Database from "./Database";
import type Field from "./Field";
import type ForeignKey from "./ForeignKey";
import type Measure from "./Measure";
import type Metadata from "./Metadata";
import type Schema from "./Schema";
import type Segment from "./Segment";
interface Table extends Omit<
NormalizedTable,

View File

@@ -51,15 +51,6 @@ export function AISettingsPage() {
} = useAdminSetting("ai-features-enabled?");
const areAiFeaturesEnabled = aiFeaturesEnabledValue !== false;
const {
value: agentApiEnabledValue,
updateSetting: updateAgentApiSetting,
isLoading: isUpdatingAgentApi,
} = useAdminSetting("agent-api-enabled?");
const isAgentApiEnabled = agentApiEnabledValue !== false;
const { url: agentApiDocsUrl } = useDocsUrl("ai/agent-api");
const selectedTab = getSelectedMetabotTab(params.metabotId, pathname, {
hasEmbedding,
});
@@ -71,17 +62,10 @@ export function AISettingsPage() {
});
};
const handleAgentApiChange = async (checked: boolean) => {
await updateAgentApiSetting({
key: "agent-api-enabled?",
value: checked,
});
};
return (
<SettingsPageWrapper
title={t`AI features`}
description={t`Manage AI integrations and Metabot.`}
description={t`Manage your AI provider connection and Metabot.`}
>
{areAiFeaturesEnabled && (
<>
@@ -94,24 +78,6 @@ export function AISettingsPage() {
/>
</DisabledSection>
<Divider />
<Stack gap="lg">
<McpAppsSettings id={MCP_SECTION_ID} />
<ToggleSettingsSection
checked={isAgentApiEnabled}
description={jt`Enable external access to the Agent API. ${(
<ExternalLink key="docs" href={agentApiDocsUrl}>
{t`Learn more`}
</ExternalLink>
)}`}
disabled={isUpdatingAgentApi}
id={AGENT_API_SECTION_ID}
onChange={handleAgentApiChange}
title={t`Agent API`}
/>
</Stack>
<Divider />
</>
)}
@@ -127,6 +93,59 @@ export function AISettingsPage() {
);
}
export function McpSettingsPage() {
const { value: aiFeaturesEnabledValue } = useAdminSetting(
"ai-features-enabled?",
);
const areAiFeaturesEnabled = aiFeaturesEnabledValue !== false;
return (
<SettingsPageWrapper
title={t`MCP`}
description={t`Manage MCP server and Agent API access.`}
>
<DisabledSection disabled={!areAiFeaturesEnabled}>
<McpAppsSettings id={MCP_SECTION_ID} />
<AgentApiSettingsSection disabled={!areAiFeaturesEnabled} />
</DisabledSection>
</SettingsPageWrapper>
);
}
function AgentApiSettingsSection({ disabled }: { disabled: boolean }) {
const {
value: agentApiEnabledValue,
updateSetting: updateAgentApiSetting,
isLoading: isUpdatingAgentApi,
} = useAdminSetting("agent-api-enabled?");
const isAgentApiEnabled = agentApiEnabledValue !== false;
const { url: agentApiDocsUrl } = useDocsUrl("ai/agent-api");
const handleAgentApiChange = async (checked: boolean) => {
await updateAgentApiSetting({
key: "agent-api-enabled?",
value: checked,
});
};
return (
<ToggleSettingsSection
checked={isAgentApiEnabled}
description={jt`Enable external access to the Agent API. ${(
<ExternalLink key="docs" href={agentApiDocsUrl}>
{t`Learn more`}
</ExternalLink>
)}`}
disabled={disabled || isUpdatingAgentApi}
id={AGENT_API_SECTION_ID}
onChange={handleAgentApiChange}
title={t`Agent API`}
/>
);
}
function MetabotSettingsSection({
hasEmbedding,
id,

View File

@@ -29,20 +29,41 @@ import {
createMockTokenFeatures,
} from "metabase-types/api/mocks";
import { AISettingsPage } from "./AISettingsPage";
import { AISettingsPage, McpSettingsPage } from "./AISettingsPage";
const defaultSeedCollections = [
createMockCollection({ id: "root", name: "Our Analytics" }),
];
type Page = "ai-features" | "mcp";
function getPageRoute(page: Page) {
switch (page) {
case "mcp":
return <Route path="/admin/metabot/mcp" component={McpSettingsPage} />;
case "ai-features":
return <Route path="/admin/metabot*" component={AISettingsPage} />;
}
}
function getInitialRoute(page: Page) {
switch (page) {
case "mcp":
return "/admin/metabot/mcp";
case "ai-features":
return "/admin/metabot";
}
}
const setup = async ({
aiFeaturesEnabled = true,
enableEmbedding = false,
contentVerification = false,
initialRoute = "/admin/metabot",
initialRoute,
isConfigured = true,
metabots = buildDefaultMetabots(),
collections = defaultSeedCollections,
page = "ai-features",
}: {
aiFeaturesEnabled?: boolean;
enableEmbedding?: boolean;
@@ -53,6 +74,7 @@ const setup = async ({
collections?: Parameters<
typeof setupCollectionByIdEndpoint
>[0]["collections"];
page?: Page;
} = {}) => {
const tokenFeatures = createMockTokenFeatures({
embedding_sdk: enableEmbedding,
@@ -111,18 +133,19 @@ const setup = async ({
}),
);
const view = renderWithProviders(
<Route path="/admin/metabot*" component={AISettingsPage} />,
{
withRouter: true,
initialRoute,
storeInitialState: {
settings: createMockSettingsState(settings),
},
const view = renderWithProviders(getPageRoute(page), {
withRouter: true,
initialRoute: initialRoute ?? getInitialRoute(page),
storeInitialState: {
settings: createMockSettingsState(settings),
},
);
});
await screen.findByText("Disable all AI features");
if (page === "mcp") {
await screen.findByRole("switch", { name: "MCP server" });
} else {
await screen.findByText("Disable all AI features");
}
return view;
};
@@ -133,34 +156,24 @@ describe("AISettingsPage", () => {
jest.restoreAllMocks();
});
it("shows all sections on one page and disables non-connection sections until configured", async () => {
it("shows AI setup and metabot settings and disables metabot until configured", async () => {
await setup({ isConfigured: false, enableEmbedding: true });
expect(screen.getByText("Connect to an AI provider")).toBeInTheDocument();
expect(screen.getByText("Metabot settings")).toBeInTheDocument();
expect(screen.getByText("Enable Metabot")).toBeInTheDocument();
expect(screen.getByText("MCP server")).toBeInTheDocument();
expect(screen.getByText("Agent API")).toBeInTheDocument();
expect(screen.queryByText("MCP server")).not.toBeInTheDocument();
expect(screen.queryByText("Agent API")).not.toBeInTheDocument();
expect(
screen.getByText("Enable Metabot", {
selector: '[aria-disabled="true"] *',
}),
).toBeInTheDocument();
expect(
screen.queryByText("MCP server", {
selector: '[aria-disabled="true"] *',
}),
).not.toBeInTheDocument();
expect(
screen.queryByText("Agent API", {
selector: '[aria-disabled="true"] *',
}),
).not.toBeInTheDocument();
});
it("shows docs links for MCP and Agent API", async () => {
await setup();
it("shows docs links on the MCP page", async () => {
await setup({ page: "mcp" });
expect(
screen
@@ -181,11 +194,27 @@ describe("AISettingsPage", () => {
screen.queryByText("Connect to an AI provider"),
).not.toBeInTheDocument();
expect(screen.queryByText("Metabot settings")).not.toBeInTheDocument();
expect(screen.queryByText("MCP server")).not.toBeInTheDocument();
expect(screen.queryByText("Agent API")).not.toBeInTheDocument();
expect(screen.getByText("Disable all AI features")).toBeInTheDocument();
});
it("keeps MCP settings enabled when Metabot is not configured", async () => {
await setup({ page: "mcp", isConfigured: false });
expect(screen.getByRole("switch", { name: "MCP server" })).toBeEnabled();
expect(screen.getByRole("switch", { name: "Agent API" })).toBeEnabled();
});
it("disables MCP settings when all AI features are disabled", async () => {
await setup({ page: "mcp", aiFeaturesEnabled: false });
expect(
screen.getByText("MCP server", {
selector: '[aria-disabled="true"] *',
}),
).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Agent API" })).toBeDisabled();
});
it("keeps the embedded deep link working by selecting the embedded tab", async () => {
await setup({
enableEmbedding: true,

View File

@@ -4,16 +4,14 @@ import {
AdminNavItem,
AdminNavWrapper,
} from "metabase/admin/components/AdminNav";
import { useSetting } from "metabase/common/hooks";
import { PLUGIN_AI_CONTROLS, PLUGIN_AUDIT } from "metabase/plugins";
import { Flex } from "metabase/ui";
export function MetabotNavPane() {
const AiControlsNavItems = PLUGIN_AI_CONTROLS.getAiControlsNavItems();
const AiAnalyticsNavItems = PLUGIN_AUDIT.getMetabotAnalyticsNavItems();
if (!AiControlsNavItems && !AiAnalyticsNavItems) {
return null;
}
const areAiFeaturesEnabled = useSetting("ai-features-enabled?") !== false;
return (
<Flex direction="column" flex="0 0 auto">
@@ -21,7 +19,13 @@ export function MetabotNavPane() {
<AdminNavItem
icon="gear"
label={t`AI Settings`}
path="/admin/metabot/"
path="/admin/metabot"
/>
<AdminNavItem
disabled={!areAiFeaturesEnabled}
icon="mcp"
label={t`MCP`}
path="/admin/metabot/mcp"
/>
{AiControlsNavItems}
{AiAnalyticsNavItems}

View File

@@ -50,7 +50,7 @@ describe("MetabotNavPane", () => {
reinitialize();
});
it("hides the ai controls items when all AI features are disabled", () => {
it("hides the ai controls items and disables MCP when all AI features are disabled", () => {
setup({
aiControlsEnabled: true,
aiFeaturesEnabled: false,
@@ -58,6 +58,9 @@ describe("MetabotNavPane", () => {
});
expect(screen.getByText("AI Settings")).toBeInTheDocument();
expect(
screen.getByText("MCP", { selector: '[data-disabled="true"] *' }),
).toBeInTheDocument();
expect(screen.queryByText("Usage controls")).not.toBeInTheDocument();
expect(screen.queryByText("Customization")).not.toBeInTheDocument();
expect(screen.queryByText("System prompts")).not.toBeInTheDocument();
@@ -67,15 +70,24 @@ describe("MetabotNavPane", () => {
setup({ aiControlsEnabled: true, isConfigured: false });
expect(await screen.findByText("AI Settings")).toBeInTheDocument();
expect(
screen.queryByText("MCP", { selector: '[data-disabled="true"] *' }),
).not.toBeInTheDocument();
expect(
screen.getByText("Usage controls").closest('[data-disabled="true"]'),
screen.getByText("Usage controls", {
selector: '[data-disabled="true"] *',
}),
).toBeInTheDocument();
expect(
screen.getByText("Customization").closest('[data-disabled="true"]'),
screen.getByText("Customization", {
selector: '[data-disabled="true"] *',
}),
).toBeInTheDocument();
expect(
screen.getByText("System prompts").closest('[data-disabled="true"]'),
screen.getByText("System prompts", {
selector: '[data-disabled="true"] *',
}),
).toBeInTheDocument();
});
@@ -83,6 +95,10 @@ describe("MetabotNavPane", () => {
setup({ aiControlsEnabled: false, aiFeaturesEnabled: true });
expect(await screen.findByText("AI Settings")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /MCP/ })).toHaveAttribute(
"href",
"/admin/metabot/mcp",
);
expect(
screen.getByRole("link", { name: /Usage controls/ }),

View File

@@ -16,6 +16,7 @@ import _ from "underscore";
import { SettingsSection } from "metabase/admin/components/SettingsSection";
import { SetByEnvVar } from "metabase/admin/settings/components/widgets/AdminSettingInput";
import {
skipToken,
useGetMetabotSettingsQuery,
useUpdateMetabotSettingsMutation,
useUpdateSettingsMutation,
@@ -137,6 +138,13 @@ export function MetabotSetup({ id }: { id?: string }) {
);
const isConfigured = !!useSetting("llm-metabot-configured?");
const connectedProvider = isConfigured ? config?.provider : undefined;
const connectedProviderSettingsQuery = useGetMetabotSettingsQuery(
connectedProvider && connectedProvider !== "metabase"
? { provider: connectedProvider }
: skipToken,
);
const hasApiKeyError =
!!connectedProviderSettingsQuery.currentData?.["api-key-error"];
return (
<SettingsSection
@@ -144,13 +152,31 @@ export function MetabotSetup({ id }: { id?: string }) {
title={
<Flex justify="space-between" align="center">
<Group gap="xs" wrap="nowrap">
{isConfigured ? (
<Badge circle size="12" bg="success" mr="sm" />
) : null}
{connectedProvider && (
<Badge
circle
size="12"
bg={hasApiKeyError ? "error" : "success"}
mr="sm"
/>
)}
<div>
{connectedProvider
? t`Connected to ${getProviderOptions(offerMetabaseAiManaged)[connectedProvider]?.label}`
: t`Connect to an AI provider`}
{match({ connectedProvider, hasApiKeyError })
.with(
{ connectedProvider: P.nonNullable, hasApiKeyError: true },
({ connectedProvider }) =>
t`Error connecting to ${getProviderOptions(offerMetabaseAiManaged)[connectedProvider]?.label}`,
)
.with(
{ connectedProvider: P.nonNullable },
({ connectedProvider }) =>
t`Connected to ${getProviderOptions(offerMetabaseAiManaged)[connectedProvider]?.label}`,
)
.with(
{ connectedProvider: P.nullish },
() => t`Connect to an AI provider`,
)
.exhaustive()}
</div>
</Group>
</Flex>
@@ -489,6 +515,9 @@ const AIProviderSetup = ({
const selectedApiKeySetting =
providerApiKeyDetails[API_KEY_SETTING_BY_PROVIDER[selectedProvider]];
const selectedApiKeyValue = String(selectedApiKeySetting?.value ?? "");
const apiKeyEnvSettingName = selectedApiKeySetting?.is_env_setting
? selectedApiKeySetting.env_name
: undefined;
const needsApiKey = !hasConfiguredSettingValue(selectedApiKeySetting);
const metabotSettingsQuery = useGetMetabotSettingsQuery(
@@ -507,6 +536,9 @@ const AIProviderSetup = ({
metabotSettingsQuery.error,
selectedProvider,
);
const apiKeyError = hasDirtyApiKey
? undefined
: (metabotSettingsQuery.currentData?.["api-key-error"] ?? undefined);
const displayApiKeyValue = apiKeyLocalValue ?? selectedApiKeyValue;
@@ -557,16 +589,17 @@ const AIProviderSetup = ({
selectedProviderDetails.apiKey?.placeholder ?? t`Enter your API key`
}
value={displayApiKeyValue}
error={apiKeyError}
onChange={handleApiKeyChange}
disabled={isLoading || isEnvSetting}
disabled={isLoading || isEnvSetting || !!apiKeyEnvSettingName}
w="100%"
/>
{isEnvSetting && selectedApiKeySetting?.env_name ? (
<SetByEnvVar varName={selectedApiKeySetting.env_name} />
{apiKeyEnvSettingName ? (
<SetByEnvVar varName={apiKeyEnvSettingName} />
) : null}
{!needsApiKey && (
{!needsApiKey && !apiKeyError && (
<Select
label={t`Model`}
placeholder={

View File

@@ -113,6 +113,8 @@ type SetupOptions = {
isConfigured?: boolean;
providerSettingIsEnv?: boolean;
providerSettingEnvName?: string;
apiKeySettingIsEnv?: boolean;
apiKeySettingEnvName?: string;
isStoreUser?: boolean;
anyStoreUserEmailAddress?: string;
metabasePricePerUnit?: number;
@@ -141,6 +143,8 @@ async function setup({
isConfigured = true,
providerSettingIsEnv = false,
providerSettingEnvName = "LLM_METABOT_PROVIDER",
apiKeySettingIsEnv = false,
apiKeySettingEnvName = "LLM_ANTHROPIC_API_KEY",
isStoreUser = isHosted,
anyStoreUserEmailAddress = "store-admin@metabase.test",
metabasePricePerUnit = 3.75,
@@ -217,6 +221,8 @@ async function setup({
"llm-anthropic-api-key": createMockSettingDefinition({
key: "llm-anthropic-api-key",
value: mergedApiKeyValues.anthropic ?? undefined,
is_env_setting: apiKeySettingIsEnv,
env_name: apiKeySettingIsEnv ? apiKeySettingEnvName : undefined,
}),
"llm-openai-api-key": createMockSettingDefinition({
key: "llm-openai-api-key",
@@ -539,6 +545,84 @@ describe("MetabotSetup", () => {
});
});
it("shows a saved API key validation error without disconnecting", async () => {
await setup({
responses: {
anthropic: {
value: "anthropic/claude-haiku-4-5",
"api-key-error": "Anthropic API key expired or invalid",
models: [],
},
},
});
expect(await screen.findByLabelText("API key")).toHaveValue("**********45");
expect(
await screen.findByText("Anthropic API key expired or invalid"),
).toBeInTheDocument();
expect(
screen.getByText("Error connecting to Anthropic"),
).toBeInTheDocument();
expect(screen.queryByLabelText("Model")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Disconnect" }),
).toBeInTheDocument();
expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument();
const settingsRequest = fetchMock.callHistory.calls(
"path:/api/metabot/settings",
)[0];
expect(settingsRequest?.url).toContain("provider=anthropic");
expect(settingsRequest?.url).not.toContain("api-key");
});
it("disables an env-backed API key field with a saved key validation error", async () => {
await setup({
apiKeySettingIsEnv: true,
responses: {
anthropic: {
value: "anthropic/claude-haiku-4-5",
"api-key-error": "Anthropic API key expired or invalid",
models: [],
},
},
});
expect(await screen.findByLabelText("API key")).toBeDisabled();
expect(
await screen.findByText("Anthropic API key expired or invalid"),
).toBeInTheDocument();
expect(screen.getByTestId("setting-env-var-message")).toHaveTextContent(
"This has been set by the LLM_ANTHROPIC_API_KEY environment variable.",
);
expect(
screen.getByRole("button", { name: "Disconnect" }),
).toBeInTheDocument();
});
it("clears the saved API key error and shows Connect when the key changes", async () => {
await setup({
responses: {
anthropic: {
value: "anthropic/claude-haiku-4-5",
"api-key-error": "Anthropic API key expired or invalid",
models: [],
},
},
});
await screen.findByText("Anthropic API key expired or invalid");
await userEvent.clear(screen.getByLabelText("API key"));
await userEvent.type(screen.getByLabelText("API key"), "sk-ant-rotated");
expect(
screen.queryByText("Anthropic API key expired or invalid"),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
});
it("shows the disconnected title when not configured", async () => {
await setup({ savedProviderValue: null, isConfigured: false });
@@ -1210,20 +1294,17 @@ describe("MetabotSetup", () => {
);
await waitFor(() => {
expect(fetchMock.callHistory.called("path:/api/setting")).toBe(true);
expect(
fetchMock.callHistory.called("path:/api/setting", {
method: "PUT",
body: {
"llm-metabot-provider": null,
"llm-anthropic-api-key": null,
},
}),
).toBe(true);
});
const [request] = fetchMock.callHistory.calls("path:/api/setting", {
method: "PUT",
});
expect(request?.options?.body).toBe(
JSON.stringify({
"llm-metabot-provider": null,
"llm-anthropic-api-key": null,
}),
);
expect(
await screen.findByText("Connect to an AI provider"),
).toBeInTheDocument();
@@ -1233,6 +1314,38 @@ describe("MetabotSetup", () => {
).not.toBeInTheDocument();
});
it("disconnects an env-backed API-key provider by clearing only the provider setting", async () => {
await setup({
apiKeySettingIsEnv: true,
responses: {
anthropic: {
value: "anthropic/claude-haiku-4-5",
"api-key-error": "Anthropic API key expired or invalid",
models: [],
},
},
});
await screen.findByText("Anthropic API key expired or invalid");
await confirmDisconnectProvider();
await waitFor(() => {
expect(
fetchMock.callHistory.called("path:/api/setting", {
method: "PUT",
body: {
"llm-metabot-provider": null,
},
}),
).toBe(true);
});
expect(
await screen.findByText("Connect to an AI provider"),
).toBeInTheDocument();
});
it("disconnects the Metabase-managed provider by removing the add-on before clearing the provider setting", async () => {
await setup({
isHosted: true,

View File

@@ -1,96 +1,53 @@
import { useEffect } from "react";
import _ from "underscore";
import { Segments } from "metabase/entities/segments";
import { Tables } from "metabase/entities/tables";
import { connect } from "metabase/redux";
import { useGetSegmentQuery } from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useLoadTableWithMetadata } from "metabase/data-studio/common/hooks/use-load-table-with-metadata";
import { useDispatch, useSelector } from "metabase/redux";
import type { State } from "metabase/redux/store";
import { getUser } from "metabase/selectors/user";
import { checkNotNull } from "metabase/utils/types";
import type { Revision, RevisionId, Segment, User } from "metabase-types/api";
import { RevisionHistory } from "../components/revisions/RevisionHistory";
import { fetchSegmentRevisions } from "../datamodel";
import { getRevisions } from "../selectors";
type RevisionHistoryAppOwnProps = {
type RevisionHistoryAppProps = {
params: {
id: string;
};
};
type RevisionHistoryAppStateProps = {
id: string;
revisions: Revision[] | null;
user: User;
};
export function RevisionHistoryApp({ params }: RevisionHistoryAppProps) {
const { id } = params;
const dispatch = useDispatch();
const user = checkNotNull(useSelector(getUser));
const revisions = useSelector((state: State) => getRevisions(state));
const segmentId = parseInt(id, 10);
type RevisionHistoryAppDispatchProps = {
fetchSegmentRevisions: (id: RevisionId | string) => void;
};
const {
data: segment,
isLoading: isLoadingSegment,
error: segmentError,
} = useGetSegmentQuery(segmentId);
type RevisionHistoryAppInnerProps = RevisionHistoryAppOwnProps &
RevisionHistoryAppStateProps &
RevisionHistoryAppDispatchProps;
const { isLoading: isLoadingTable, error: tableError } =
useLoadTableWithMetadata(segment?.table_id, {
includeForeignTables: true,
});
const mapStateToProps = (
state: State,
props: RevisionHistoryAppOwnProps,
): RevisionHistoryAppStateProps => ({
id: props.params.id,
revisions: getRevisions(state),
user: checkNotNull(getUser(state)),
});
const mapDispatchToProps: RevisionHistoryAppDispatchProps = {
fetchSegmentRevisions,
};
function RevisionHistoryAppInner({
id,
user,
revisions,
fetchSegmentRevisions,
...props
}: RevisionHistoryAppInnerProps) {
useEffect(() => {
fetchSegmentRevisions(id);
}, [id, fetchSegmentRevisions]);
dispatch(fetchSegmentRevisions(id));
}, [dispatch, id]);
const isLoading = isLoadingSegment || isLoadingTable;
const error = segmentError ?? tableError;
if (isLoading || error || !segment) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
return (
<SegmentRevisionHistory
{...props}
id={id}
user={user}
revisions={revisions}
fetchSegmentRevisions={fetchSegmentRevisions}
/>
<RevisionHistory revisions={revisions} segment={segment} user={user} />
);
}
export const RevisionHistoryApp = connect(
mapStateToProps,
mapDispatchToProps,
)(RevisionHistoryAppInner);
type SegmentRevisionHistoryInnerProps = RevisionHistoryAppStateProps &
RevisionHistoryAppDispatchProps & {
segment: Segment;
};
function SegmentRevisionHistoryInner({
segment,
...props
}: SegmentRevisionHistoryInnerProps) {
return <RevisionHistory segment={segment} {...props} />;
}
const SegmentRevisionHistory = _.compose(
Segments.load({ id: (_state: State, { id }: { id: string }) => id }),
Tables.load({
id: (_state: State, { segment }: { segment?: Segment }) =>
segment?.table_id,
fetchType: "fetchMetadataAndForeignTables",
requestType: "fetchMetadataDeprecated",
}),
)(SegmentRevisionHistoryInner);

View File

@@ -2,17 +2,24 @@ import { useCallback, useState } from "react";
import type { Route } from "react-router";
import { push } from "react-router-redux";
import { t } from "ttag";
import _ from "underscore";
import {
useCreateSegmentMutation,
useGetSegmentQuery,
useUpdateSegmentMutation,
} from "metabase/api";
import { LeaveRouteConfirmModal } from "metabase/common/components/LeaveConfirmModal";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useCallbackEffect } from "metabase/common/hooks/use-callback-effect";
import { trackSegmentCreated } from "metabase/data-studio/analytics";
import { Segments } from "metabase/entities/segments";
import { Tables } from "metabase/entities/tables";
import { useLoadTableWithMetadata } from "metabase/data-studio/common/hooks/use-load-table-with-metadata";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { connect } from "metabase/redux";
import type { State } from "metabase/redux/store";
import type { Segment } from "metabase-types/api";
import { useDispatch } from "metabase/redux";
import type {
CreateSegmentRequest,
Segment,
UpdateSegmentRequest,
} from "metabase-types/api";
import { SegmentForm } from "../components/SegmentForm";
@@ -23,62 +30,54 @@ type SegmentAppOwnProps = {
route: Route;
};
type NormalizedSegmentResponse = {
payload: {
segment: Segment;
};
type UpdateSegmentFormProps = {
route: Route;
segmentId: number;
};
type SegmentAppDispatchProps = {
createSegment: (
segment: Partial<Segment>,
) => Promise<NormalizedSegmentResponse>;
updateSegment: (
segment: Partial<Segment>,
) => Promise<NormalizedSegmentResponse>;
onChangeLocation: (path: string) => void;
};
const mapDispatchToProps: SegmentAppDispatchProps = {
createSegment: Segments.actions.create,
updateSegment: Segments.actions.update,
onChangeLocation: push,
};
type UpdateSegmentFormInnerProps = SegmentAppOwnProps &
SegmentAppDispatchProps & {
segment: Segment & {
// Attributes from entity framework object wrapper
getPlainObject(): Segment;
};
};
function UpdateSegmentFormInner({
route,
segment,
updateSegment,
onChangeLocation,
}: UpdateSegmentFormInnerProps) {
function UpdateSegmentForm({ route, segmentId }: UpdateSegmentFormProps) {
const dispatch = useDispatch();
const [isDirty, setIsDirty] = useState(false);
const [updateSegment] = useUpdateSegmentMutation();
const {
data: segment,
isLoading: isLoadingSegment,
error: segmentError,
} = useGetSegmentQuery(segmentId);
const { isLoading: isLoadingTable, error: tableError } =
useLoadTableWithMetadata(segment?.table_id, {
includeForeignTables: true,
});
const handleSubmit = useCallback(
async (segmentValues: Partial<Segment>) => {
setIsDirty(false);
try {
await updateSegment(segmentValues);
onChangeLocation("/admin/datamodel/segments");
} catch {
const result = await updateSegment(
toUpdateSegmentRequest(segmentId, segmentValues),
);
if (result.error) {
setIsDirty(isDirty);
return;
}
dispatch(push("/admin/datamodel/segments"));
},
[updateSegment, isDirty, onChangeLocation],
[dispatch, segmentId, updateSegment, isDirty],
);
const isLoading = isLoadingSegment || isLoadingTable;
const error = segmentError ?? tableError;
if (isLoading || error || !segment) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
return (
<>
<SegmentForm
segment={segment.getPlainObject()}
segment={segment}
onIsDirtyChange={setIsDirty}
onSubmit={handleSubmit}
/>
@@ -88,29 +87,15 @@ function UpdateSegmentFormInner({
);
}
const UpdateSegmentForm = _.compose(
Segments.load({
id: (_state: State, { params }: SegmentAppOwnProps) =>
parseInt(params.id, 10),
}),
Tables.load({
id: (_state: State, { segment }: { segment?: Segment }) =>
segment?.table_id,
fetchType: "fetchMetadataAndForeignTables",
requestType: "fetchMetadataDeprecated",
}),
)(UpdateSegmentFormInner);
type CreateSegmentFormProps = {
route: Route;
};
type CreateSegmentFormProps = SegmentAppOwnProps & SegmentAppDispatchProps;
function CreateSegmentForm({
createSegment,
route,
onChangeLocation,
...props
}: CreateSegmentFormProps) {
function CreateSegmentForm({ route }: CreateSegmentFormProps) {
const dispatch = useDispatch();
const [isDirty, setIsDirty] = useState(false);
const { sendErrorToast } = useMetadataToasts();
const [createSegment] = useCreateSegmentMutation();
/**
* Navigation is scheduled so that LeaveConfirmationModal's isEnabled
@@ -123,52 +108,54 @@ function CreateSegmentForm({
setIsDirty(false);
scheduleCallback(async () => {
try {
const { payload } = await createSegment(segment);
trackSegmentCreated(
"success",
"admin_datamodel_segments",
payload.segment?.id,
);
onChangeLocation("/admin/datamodel/segments");
} catch (error) {
const result = await createSegment(segment as CreateSegmentRequest);
if (result.error) {
sendErrorToast(t`Failed to create segment`);
trackSegmentCreated("failure", "admin_datamodel_segments");
setIsDirty(isDirty);
console.warn(error);
console.warn(result.error);
return;
}
trackSegmentCreated(
"success",
"admin_datamodel_segments",
result.data?.id,
);
dispatch(push("/admin/datamodel/segments"));
});
},
[
scheduleCallback,
createSegment,
onChangeLocation,
sendErrorToast,
isDirty,
],
[scheduleCallback, createSegment, dispatch, sendErrorToast, isDirty],
);
return (
<>
<SegmentForm
{...props}
onIsDirtyChange={setIsDirty}
onSubmit={handleSubmit}
/>
<SegmentForm onIsDirtyChange={setIsDirty} onSubmit={handleSubmit} />
<LeaveRouteConfirmModal isEnabled={isDirty} route={route} />
</>
);
}
type SegmentAppInnerProps = SegmentAppOwnProps & SegmentAppDispatchProps;
function SegmentAppInner(props: SegmentAppInnerProps) {
if (props.params.id) {
return <UpdateSegmentForm {...props} />;
export function SegmentApp({ params, route }: SegmentAppOwnProps) {
if (params.id) {
return (
<UpdateSegmentForm route={route} segmentId={parseInt(params.id, 10)} />
);
}
return <CreateSegmentForm {...props} />;
return <CreateSegmentForm route={route} />;
}
export const SegmentApp = connect(null, mapDispatchToProps)(SegmentAppInner);
function toUpdateSegmentRequest(
id: number,
values: Partial<Segment>,
): UpdateSegmentRequest {
return {
id,
name: values.name,
description: values.description,
definition: values.definition,
archived: values.archived,
revision_message: values.revision_message ?? "",
};
}

View File

@@ -1,37 +1,36 @@
import cx from "classnames";
import type { Location } from "history";
import type { ReactNode } from "react";
import { t } from "ttag";
import _ from "underscore";
import { SegmentItem } from "metabase/admin/datamodel/components/SegmentItem";
import { FilteredToUrlTable } from "metabase/admin/datamodel/hoc/FilteredToUrlTable";
import { useListSegmentsQuery } from "metabase/api";
import { Button } from "metabase/common/components/Button";
import { Link } from "metabase/common/components/Link";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useSetArchive } from "metabase/common/hooks";
import AdminS from "metabase/css/admin.module.css";
import CS from "metabase/css/core/index.css";
import { trackSegmentCreateStarted } from "metabase/data-studio/analytics";
import { Segments } from "metabase/entities/segments";
import { PLUGIN_REMOTE_SYNC } from "metabase/plugins";
import { connect } from "metabase/redux";
import type { State } from "metabase/redux/store";
import { useSelector } from "metabase/redux";
import { getShallowTables } from "metabase/selectors/metadata";
import { getUserIsAdmin } from "metabase/selectors/user";
import * as Urls from "metabase/urls";
import type { Segment } from "metabase-types/api";
interface Props {
isAdmin: boolean;
isRemoteSyncReadOnly: boolean;
segments: Segment[];
tableSelector: ReactNode;
}
function SegmentListAppInner({
isAdmin,
isRemoteSyncReadOnly,
segments,
tableSelector,
}: Props) {
function SegmentListAppInner({ segments, tableSelector }: Props) {
const isAdmin = useSelector(getUserIsAdmin);
const isRemoteSyncReadOnly = useSelector(
PLUGIN_REMOTE_SYNC.getIsRemoteSyncReadOnly,
);
const tables = useSelector(getShallowTables);
const archive = useSetArchive();
const trackSegmentCreateClick = () => {
trackSegmentCreateStarted("admin_datamodel_segments");
@@ -71,7 +70,9 @@ function SegmentListAppInner({
<SegmentItem
key={segment.id}
segment={segment}
readOnly={segment.table?.is_published && isRemoteSyncReadOnly}
readOnly={
tables[segment.table_id]?.is_published && isRemoteSyncReadOnly
}
onRetire={
isAdmin
? () => archive({ id: segment.id, model: "segment" }, true)
@@ -91,11 +92,18 @@ function SegmentListAppInner({
);
}
export const SegmentListApp = _.compose(
Segments.loadList(),
FilteredToUrlTable,
connect((state: State) => ({
isAdmin: getUserIsAdmin(state),
isRemoteSyncReadOnly: PLUGIN_REMOTE_SYNC.getIsRemoteSyncReadOnly(state),
})),
)(SegmentListAppInner);
const FilteredSegmentList = FilteredToUrlTable(SegmentListAppInner);
type SegmentListAppProps = {
location: Location<{ table?: string }>;
};
export function SegmentListApp({ location }: SegmentListAppProps) {
const { data: segments, isLoading, error } = useListSegmentsQuery();
if (isLoading || error || !segments) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
return <FilteredSegmentList location={location} segments={segments} />;
}

View File

@@ -66,7 +66,7 @@ import {
import type { State } from "metabase/redux/store";
import { getTokenFeature } from "metabase/setup";
import { AISettingsPage } from "./ai/AISettingsPage";
import { AISettingsPage, McpSettingsPage } from "./ai/AISettingsPage";
import { MetabotAdminLayout } from "./ai/MetabotAdminLayout";
import { ModelPersistenceConfiguration } from "./performance/components/ModelPersistenceConfiguration";
import { StrategyEditorForDatabases } from "./performance/components/StrategyEditorForDatabases";
@@ -271,6 +271,7 @@ export const getRoutes = (
{PLUGIN_AUDIT.getAiAnalyticsRoutes()}
<Route key="index-layout" component={MetabotAdminLayout}>
<IndexRoute key="index" component={AISettingsPage} />
<Route key="mcp" path="mcp" component={McpSettingsPage} />
<Route key="metabot" path=":metabotId" component={AISettingsPage} />
</Route>
<Route

View File

@@ -9,13 +9,11 @@ import type {
OnMove,
OnToggleSelectedWithItem,
} from "metabase/collections/types";
import {
isRootTrashCollection,
isTrashedCollection,
} from "metabase/collections/utils";
import { isTrashedCollection } from "metabase/collections/utils";
import { BaseItemsTableBody } from "metabase/common/components/ItemsTable/BaseItemsTableBody/BaseItemsTableBody";
import type { ItemRendererProps } from "metabase/common/components/ItemsTable/DefaultItemRenderer";
import { DefaultItemRenderer } from "metabase/common/components/ItemsTable/DefaultItemRenderer";
import { canSelectItems } from "metabase/common/components/ItemsTable/utils";
import type Database from "metabase-lib/v1/metadata/Database";
import type {
Bookmark,
@@ -156,9 +154,7 @@ export const BaseItemsTable = ({
onClick,
...props
}: BaseItemsTableProps) => {
const canSelect =
(collection?.can_write || isRootTrashCollection(collection)) &&
typeof onToggleSelected === "function";
const canSelect = canSelectItems(collection, onToggleSelected);
const isTrashed = !!collection && isTrashedCollection(collection);
return (

View File

@@ -2,6 +2,8 @@ import userEvent from "@testing-library/user-event";
import dayjs from "dayjs";
import { Route } from "react-router";
import { setupEnterpriseOnlyPlugin } from "__support__/enterprise";
import { mockSettings } from "__support__/settings";
import { getIcon, renderWithProviders, screen } from "__support__/ui";
import { DEFAULT_VISIBLE_COLUMNS_LIST } from "metabase/collections/components/CollectionContent";
import { getVisibleColumnsMap } from "metabase/common/components/ItemsTable/utils";
@@ -12,7 +14,10 @@ import {
DEFAULT_TIME_STYLE,
} from "metabase/utils/formatting/datetime-utils";
import type { CollectionItem } from "metabase-types/api";
import { createMockCollection } from "metabase-types/api/mocks";
import {
createMockCollection,
createMockTokenFeatures,
} from "metabase-types/api/mocks";
import type { BaseItemsTableProps } from "./BaseItemsTable";
import { BaseItemsTable } from "./BaseItemsTable";
@@ -134,6 +139,31 @@ describe("BaseItemsTable", () => {
expect(screen.queryByLabelText("Select all items")).not.toBeInTheDocument();
});
describe("library collections", () => {
beforeEach(() => {
mockSettings({
"token-features": createMockTokenFeatures({ library: true }),
});
setupEnterpriseOnlyPlugin("library");
});
it("does not display select all checkbox for library collections", () => {
setup({
hasUnselected: true,
onSelectAll: jest.fn(),
onToggleSelected: jest.fn(),
collection: createMockCollection({
can_write: true,
type: "library",
}),
});
expect(
screen.queryByLabelText("Select all items"),
).not.toBeInTheDocument();
});
});
describe("description", () => {
it("shows description on hover", async () => {
const DESCRIPTION = "My collection";

View File

@@ -2,9 +2,9 @@ import { useCallback } from "react";
import type { ActionMenuProps } from "metabase/collections/components/ActionMenu";
import type { OnToggleSelectedWithItem } from "metabase/collections/types";
import { isRootTrashCollection } from "metabase/collections/utils";
import type { BaseItemsTableProps } from "metabase/common/components/ItemsTable/BaseItemsTable";
import { Columns } from "metabase/common/components/ItemsTable/Columns";
import { canSelectItems } from "metabase/common/components/ItemsTable/utils";
import { useGetIcon } from "metabase/hooks/use-icon";
import type Database from "metabase-lib/v1/metadata/Database";
import type { Bookmark, Collection, CollectionItem } from "metabase-types/api";
@@ -39,9 +39,7 @@ export const DefaultItemRenderer = ({
visibleColumnsMap,
}: ItemRendererProps) => {
const getIcon = useGetIcon();
const canSelect =
(collection?.can_write || isRootTrashCollection(collection)) &&
typeof onToggleSelected === "function";
const canSelect = canSelectItems(collection, onToggleSelected);
const icon = getIcon(item);
if (item.model === "card" || item.archived) {

View File

@@ -2,7 +2,11 @@ import type {
CollectionContentTableColumn,
CollectionContentTableColumnsMap,
} from "metabase/collections/components/CollectionContent";
import type { OnToggleSelectedWithItem } from "metabase/collections/types";
import { isRootTrashCollection } from "metabase/collections/utils";
import { PLUGIN_LIBRARY } from "metabase/plugins";
import { type BreakpointName, breakpoints } from "metabase/ui/theme";
import type { Collection } from "metabase-types/api";
export interface ResponsiveProps {
/** The element will be hidden when the container's width is below this breakpoint */
@@ -24,3 +28,16 @@ export const getVisibleColumnsMap = (
result[item] = true;
return result;
}, {} as CollectionContentTableColumnsMap);
export const canSelectItems = (
collection: Collection | undefined,
onToggleSelected: OnToggleSelectedWithItem | undefined,
): boolean => {
if (typeof onToggleSelected !== "function") {
return false;
}
if (PLUGIN_LIBRARY.isLibraryCollectionType(collection?.type)) {
return false;
}
return Boolean(collection?.can_write) || isRootTrashCollection(collection);
};

View File

@@ -1,12 +1,6 @@
import { useCallback, useMemo } from "react";
import { t } from "ttag";
import {
type OmniPickerItem,
isInDbTree,
} from "metabase/common/components/Pickers";
import { DashboardPickerModal } from "metabase/common/components/Pickers/DashboardPicker";
import { getCollectionType } from "metabase/common/components/Pickers/EntityPicker/utils";
import { canPlaceEntityInCollectionOrDescendants } from "metabase/data-studio/utils";
import { ROOT_COLLECTION } from "metabase/entities/collections";
import { useSelector } from "metabase/redux";
@@ -14,6 +8,10 @@ import { getUserPersonalCollectionId } from "metabase/selectors/user";
import * as Urls from "metabase/urls";
import type { Card, Dashboard } from "metabase-types/api";
import { DashboardPickerModal } from "../DashboardPicker";
import { type OmniPickerItem, isInDbTree } from "../EntityPicker";
import { getCollectionType } from "../EntityPicker/utils";
import { useMostRecentlyViewedDashboard } from "./hooks";
import { isInPersonalCollection } from "./utils";

View File

@@ -1,6 +1,7 @@
import type { OmniPickerItem } from "metabase/common/components/Pickers";
import type { CollectionId } from "metabase-types/api";
import type { OmniPickerItem } from "../EntityPicker";
export const isInPersonalCollection = (
dash: OmniPickerItem,
personalCollectionId: CollectionId,

View File

@@ -1,4 +1,5 @@
export * from "./EntityPicker";
export { AddToDashSelectDashModal } from "./AddToDashSelectDashModal";
export { CollectionPickerModal } from "./CollectionPicker";
export { DashboardPickerModal } from "./DashboardPicker";
export { DataPickerModal } from "./DataPicker";

View File

@@ -825,6 +825,7 @@ export const fetchDashboard = createAsyncThunk(
result.param_fields,
metadata,
);
const parameterValuesById = preserveParameters
? getParameterValues(getState())
: getParameterValuesByIdFromQueryParams(

View File

@@ -39,6 +39,6 @@
.MoveParameterMenuOption[aria-selected="true"] {
.MoveParameterMenuOptionIcon,
.MoveParameterMenuOptionText {
color: var(--mb-color-text-primary-inverse);
color: var(--mb-color-text-selected);
}
}

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { useId } from "react";
import { t } from "ttag";
import { DateTime } from "metabase/common/components/DateTime";
import { Link } from "metabase/common/components/Link";
import { useNumberFormatter } from "metabase/common/hooks/use-number-formatter";
import { PLUGIN_DEPENDENCIES } from "metabase/plugins";
@@ -15,7 +16,6 @@ interface Props {
}
export function TableMetadata({ table }: Props) {
const formattedDate = new Date(table.updated_at).toLocaleString();
const formatNumber = useNumberFormatter();
const isDependenciesEnabled = PLUGIN_DEPENDENCIES.isEnabled;
@@ -28,7 +28,10 @@ export function TableMetadata({ table }: Props) {
return (
<Stack gap="md">
<MetadataRow label={t`Name in the database`} value={table.name} />
<MetadataRow label={t`Last updated at`} value={formattedDate} />
<MetadataRow
label={t`Last updated at`}
value={<DateTime value={table.updated_at} />}
/>
<MetadataRow
label={t`View count`}
value={formatNumber(table.view_count)}

View File

@@ -0,0 +1,40 @@
import { mockSettings } from "__support__/settings";
import { renderWithProviders, screen } from "__support__/ui";
import type { DateFormattingSettings } from "metabase-types/api";
import { createMockTable } from "metabase-types/api/mocks";
import { TableMetadata } from "./TableMetadata";
describe("TableMetadata", () => {
function setup(temporalFormatting: DateFormattingSettings) {
const settings = mockSettings({
"custom-formatting": { "type/Temporal": temporalFormatting },
});
const table = createMockTable({ updated_at: "2021-06-08T14:40:10" });
renderWithProviders(<TableMetadata table={table} />, {
storeInitialState: { settings },
});
}
it("formats the last updated at date with a long weekday/24h style", () => {
setup({
date_style: "dddd, MMMM D, YYYY",
time_style: "HH:mm",
});
expect(
screen.getByText("Tuesday, June 8, 2021, 14:40"),
).toBeInTheDocument();
});
it("formats the last updated at date with a short date/12h style", () => {
setup({
date_style: "MMMM D, YYYY",
time_style: "h:mm A",
});
expect(screen.getByText("June 8, 2021, 2:40 PM")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,29 @@
import { type ReactNode, useCallback } from "react";
import type { ParameterChangePayload } from "embedding-sdk-bundle/types/dashboard";
import { isEmbeddingEajs } from "metabase/embedding-sdk/config";
import { sendMessage } from "../hooks/use-sdk-iframe-embed-event-bus";
type DashboardParametersBridgeProps = {
children: (api: {
onParametersChange: (payload: ParameterChangePayload) => void;
}) => ReactNode;
};
export const DashboardParametersBridge = ({
children,
}: DashboardParametersBridgeProps) => {
const onParametersChange = useCallback((payload: ParameterChangePayload) => {
if (!isEmbeddingEajs()) {
return;
}
sendMessage({
type: "metabase.embed.parametersChange",
data: payload,
});
}, []);
return <>{children({ onParametersChange })}</>;
};

View File

@@ -0,0 +1,32 @@
import { type ReactNode, useCallback } from "react";
import type { SqlParameterChangePayload } from "embedding-sdk-bundle/types/question";
import { isEmbeddingEajs } from "metabase/embedding-sdk/config";
import { sendMessage } from "../hooks/use-sdk-iframe-embed-event-bus";
type QuestionParametersBridgeProps = {
children: (api: {
onSqlParametersChange: (payload: SqlParameterChangePayload) => void;
}) => ReactNode;
};
export const QuestionParametersBridge = ({
children,
}: QuestionParametersBridgeProps) => {
const onSqlParametersChange = useCallback(
(payload: SqlParameterChangePayload) => {
if (!isEmbeddingEajs()) {
return;
}
sendMessage({
type: "metabase.embed.sqlParametersChange",
data: payload,
});
},
[],
);
return <>{children({ onSqlParametersChange })}</>;
};

View File

@@ -35,7 +35,9 @@ import { useSdkIframeEmbedEventBus } from "../hooks/use-sdk-iframe-embed-event-b
import type { SdkIframeEmbedSettings } from "../types/embed";
import { stripInternalIframeQueryParameters } from "../utils/strip-internal-iframe-query-parameters";
import { DashboardParametersBridge } from "./DashboardParametersBridge";
import { MetabaseBrowser } from "./MetabaseBrowser";
import { QuestionParametersBridge } from "./QuestionParametersBridge";
import SdkIframeEmbedRouteS from "./SdkIframeEmbedRoute.module.css";
import {
SdkIframeApiKeyInProductionError,
@@ -217,16 +219,22 @@ const SdkIframeEmbedView = ({
};
return (
<StaticDashboard
key={rerenderKey}
className={SdkIframeEmbedRouteS.Dashboard}
{...entityProps}
autoRefreshInterval={settings.autoRefreshInterval}
withTitle={settings.withTitle}
withDownloads={settings.withDownloads}
initialParameters={settings.initialParameters}
hiddenParameters={settings.hiddenParameters}
/>
<DashboardParametersBridge>
{({ onParametersChange }) => (
<StaticDashboard
key={rerenderKey}
className={SdkIframeEmbedRouteS.Dashboard}
{...entityProps}
autoRefreshInterval={settings.autoRefreshInterval}
withTitle={settings.withTitle}
withDownloads={settings.withDownloads}
initialParameters={settings.initialParameters}
parameters={settings.parameters}
onParametersChange={onParametersChange}
hiddenParameters={settings.hiddenParameters}
/>
)}
</DashboardParametersBridge>
);
},
)
@@ -238,21 +246,27 @@ const SdkIframeEmbedView = ({
drills: P.optional(true),
},
(settings) => (
<InteractiveDashboard
key={rerenderKey}
className={SdkIframeEmbedRouteS.Dashboard}
dashboardId={settings.dashboardId ?? null}
token={settings.token}
autoRefreshInterval={settings.autoRefreshInterval}
withTitle={settings.withTitle}
withDownloads={settings.withDownloads}
withSubscriptions={settings.withSubscriptions}
initialParameters={settings.initialParameters}
hiddenParameters={settings.hiddenParameters}
enableEntityNavigation={settings.enableEntityNavigation}
drillThroughQuestionHeight="100%"
drillThroughQuestionProps={{ isSaveEnabled: false }}
/>
<DashboardParametersBridge>
{({ onParametersChange }) => (
<InteractiveDashboard
key={rerenderKey}
className={SdkIframeEmbedRouteS.Dashboard}
dashboardId={settings.dashboardId ?? null}
token={settings.token}
autoRefreshInterval={settings.autoRefreshInterval}
withTitle={settings.withTitle}
withDownloads={settings.withDownloads}
withSubscriptions={settings.withSubscriptions}
initialParameters={settings.initialParameters}
parameters={settings.parameters}
onParametersChange={onParametersChange}
hiddenParameters={settings.hiddenParameters}
enableEntityNavigation={settings.enableEntityNavigation}
drillThroughQuestionHeight="100%"
drillThroughQuestionProps={{ isSaveEnabled: false }}
/>
)}
</DashboardParametersBridge>
),
)
// Exists solely to discriminate type from the pattern below when matching `guestEmbedProviderUri: P.nonNullable`
@@ -298,16 +312,22 @@ const SdkIframeEmbedView = ({
};
return (
<StaticQuestion
key={rerenderKey}
{...entityProps}
withDownloads={settings.withDownloads}
withAlerts={settings.withAlerts}
height="100%"
initialSqlParameters={settings.initialSqlParameters}
hiddenParameters={settings.hiddenParameters}
title={settings.withTitle ?? true}
/>
<QuestionParametersBridge>
{({ onSqlParametersChange }) => (
<StaticQuestion
key={rerenderKey}
{...entityProps}
withDownloads={settings.withDownloads}
withAlerts={settings.withAlerts}
height="100%"
initialSqlParameters={settings.initialSqlParameters}
sqlParameters={settings.sqlParameters}
onSqlParametersChange={onSqlParametersChange}
hiddenParameters={settings.hiddenParameters}
title={settings.withTitle ?? true}
/>
)}
</QuestionParametersBridge>
);
},
)
@@ -319,20 +339,26 @@ const SdkIframeEmbedView = ({
questionId: P.nonNullable,
},
(settings) => (
<SdkQuestion
key={rerenderKey}
questionId={settings.questionId ?? null}
token={settings.token}
withDownloads={settings.withDownloads}
withAlerts={settings.withAlerts}
height="100%"
initialSqlParameters={settings.initialSqlParameters}
hiddenParameters={settings.hiddenParameters}
title={settings.withTitle ?? true}
isSaveEnabled={settings.isSaveEnabled ?? false}
targetCollection={settings.targetCollection}
entityTypes={settings.entityTypes}
/>
<QuestionParametersBridge>
{({ onSqlParametersChange }) => (
<SdkQuestion
key={rerenderKey}
questionId={settings.questionId ?? null}
token={settings.token}
withDownloads={settings.withDownloads}
withAlerts={settings.withAlerts}
height="100%"
initialSqlParameters={settings.initialSqlParameters}
sqlParameters={settings.sqlParameters}
onSqlParametersChange={onSqlParametersChange}
hiddenParameters={settings.hiddenParameters}
title={settings.withTitle ?? true}
isSaveEnabled={settings.isSaveEnabled ?? false}
targetCollection={settings.targetCollection}
entityTypes={settings.entityTypes}
/>
)}
</QuestionParametersBridge>
),
)
.with(

View File

@@ -37,6 +37,7 @@ export const ALLOWED_EMBED_SETTING_KEYS_MAP = {
"withDownloads",
"withSubscriptions",
"initialParameters",
"parameters",
"hiddenParameters",
"drills",
"enableEntityNavigation",
@@ -49,6 +50,7 @@ export const ALLOWED_EMBED_SETTING_KEYS_MAP = {
"withDownloads",
"withAlerts",
"initialSqlParameters",
"sqlParameters",
"hiddenParameters",
"drills",
"entityTypes",
@@ -93,6 +95,7 @@ export const ALLOWED_GUEST_EMBED_SETTING_KEYS_MAP = {
"withTitle",
"withDownloads",
"initialParameters",
"parameters",
] satisfies (keyof DashboardEmbedOptions)[],
chart: [
"token",
@@ -100,6 +103,7 @@ export const ALLOWED_GUEST_EMBED_SETTING_KEYS_MAP = {
"withDownloads",
"entityTypes",
"initialSqlParameters",
"sqlParameters",
] satisfies (keyof QuestionEmbedOptions)[],
exploration: [] satisfies (keyof ExplorationEmbedOptions)[],
browser: [] satisfies (keyof BrowserEmbedOptions)[],

View File

@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from "react";
import {
ParametersPlayground,
useControlledParametersPlaygroundState,
} from "embedding-sdk-bundle/test/ParametersPlayground";
import type { ParameterChangePayload } from "embedding-sdk-bundle/types/dashboard";
import type { ParameterValues } from "metabase/embedding-sdk/types/dashboard";
import { SegmentedControl, Stack, Text } from "metabase/ui";
import type { MetabaseDashboardElement } from "./embed";
type Mode = "controlled" | "uncontrolled";
const INSTANCE_URL =
(window as any).METABASE_INSTANCE_URL || "http://localhost:3000";
const DASHBOARD_ID = (window as any).DASHBOARD_ID || 1;
// Module-level init: the custom element reads `window.metabaseConfig`
// in `connectedCallback`, so `window.metabaseConfig` must already be set by the time the
// element mounts. `useMemo` / `useEffect` aren't suitable — `useMemo`
// is for memoization (not side effects) and `useEffect` runs after
// children commit, too late for a child custom element's mount.
(window as any).metabaseConfig = {
instanceUrl: INSTANCE_URL,
useExistingUserSession: true,
};
const ControlledParametersEmbedJsPlayground = () => {
const elementRef = useRef<InstanceType<
typeof MetabaseDashboardElement
> | null>(null);
const [ready, setReady] = useState(false);
const [readValue, setReadValue] = useState<ParameterValues | null>(null);
const [mode, setMode] = useState<Mode>("controlled");
const playground = useControlledParametersPlaygroundState({
onLocalChange: (next) => {
if (mode === "controlled" && elementRef.current) {
elementRef.current.parameters = next;
}
},
});
useEffect(() => {
const element = elementRef.current;
if (!element) {
return;
}
const onReady = () => {
setReady(true);
playground.pushLog("ready");
};
element.addEventListener("ready", onReady);
let onParametersChange: ((event: Event) => void) | null = null;
if (mode === "controlled") {
onParametersChange = (event: Event) => {
const detail = (event as CustomEvent<ParameterChangePayload>).detail;
playground.handleParametersChange(detail);
};
element.addEventListener("parameters-change", onParametersChange);
}
return () => {
element.removeEventListener("ready", onReady);
if (onParametersChange) {
element.removeEventListener("parameters-change", onParametersChange);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
return (
<ParametersPlayground
{...playground}
title="Embed.js — parameters playground"
description={
<Stack gap="xs">
<Text size="sm" c="text-secondary">
Initial seed comes from the <code>initial-parameters</code>{" "}
attribute (set once at mount).
<br />
<b>Controlled</b>: <code>el.parameters = ...</code> push +{" "}
<code>parameters-change</code> listener attached. Parent state
mirrors iframe state.
<br />
<b>Uncontrolled</b>: no listener; user edits in the widget stay
inside the iframe and don&apos;t reach parent state. Push controls
are no-ops in this mode.
<br />
Dashboard runs inside the iframe at {INSTANCE_URL} (logged-in
session required).
</Text>
<SegmentedControl
size="xs"
value={mode}
onChange={(value) => setMode(value as Mode)}
data={[
{ value: "controlled", label: "controlled" },
{ value: "uncontrolled", label: "uncontrolled" },
]}
/>
</Stack>
}
ready={ready}
onGetNow={() => {
const values: ParameterValues | undefined =
elementRef.current?.parameters;
setReadValue(values ?? null);
playground.pushLog(`el.parameters → ${JSON.stringify(values)}`);
}}
readValue={readValue}
dashboard={
// @ts-expect-error - unknown custom element
<metabase-dashboard
ref={elementRef}
dashboard-id={DASHBOARD_ID}
parameters={JSON.stringify({ product_category: "Foo" })}
style={{ width: "100%", height: "100%" }}
/>
}
/>
);
};
export default {
title: "EmbedJs/Dashboard",
parameters: { layout: "fullscreen" },
};
export const ControlledParameters = {
render: () => <ControlledParametersEmbedJsPlayground />,
};

View File

@@ -1,11 +1,13 @@
import { MetabaseError, SSO_NOT_ALLOWED } from "embedding-sdk-bundle/errors";
import * as MetabaseErrors from "embedding-sdk-bundle/errors";
import type { SqlParameterValues } from "embedding-sdk-bundle/types";
import { PLUGIN_EMBED_JS_EE } from "metabase/embedding/embedding-iframe-sdk/plugin";
import type {
EmbedAuthManager,
EmbedAuthManagerContext,
} from "metabase/embedding/embedding-iframe-sdk/types/auth-manager";
import type { ComponentToAttributes } from "metabase/embedding/embedding-iframe-sdk/types/modular-embedding";
import type { ParameterValues } from "metabase/embedding-sdk/types/dashboard";
import { decodeJwt } from "metabase/utils/jwt";
import { debouncedReportAnalytics } from "./analytics";
@@ -159,6 +161,7 @@ export abstract class MetabaseEmbedElement<T extends string[] = string[]>
Set<SdkIframeEmbedEventHandler>
> = new Map();
private _authManager: EmbedAuthManager | null = null;
["custom-context"]: unknown;
constructor() {
@@ -481,6 +484,18 @@ export abstract class MetabaseEmbedElement<T extends string[] = string[]>
"*",
);
}
if (event.data.type === "metabase.embed.parametersChange") {
this.dispatchEvent(
new CustomEvent("parameters-change", { detail: event.data.data }),
);
}
if (event.data.type === "metabase.embed.sqlParametersChange") {
this.dispatchEvent(
new CustomEvent("sql-parameters-change", { detail: event.data.data }),
);
}
};
sendMessage<Message extends SdkIframeEmbedMessage>(
@@ -510,7 +525,7 @@ export abstract class MetabaseEmbedElement<T extends string[] = string[]>
}
}
private reportAuthenticationError(error: unknown) {
private _reportAuthenticationError(error: unknown) {
this.sendMessage("metabase.embed.reportAuthenticationError", {
error:
error instanceof MetabaseError
@@ -524,7 +539,7 @@ export abstract class MetabaseEmbedElement<T extends string[] = string[]>
private async _authenticate() {
if (!this._authManager) {
this.reportAuthenticationError(SSO_NOT_ALLOWED());
this._reportAuthenticationError(SSO_NOT_ALLOWED());
return;
}
@@ -546,7 +561,7 @@ export abstract class MetabaseEmbedElement<T extends string[] = string[]>
questionId: undefined,
});
} catch (error) {
this.reportAuthenticationError(error);
this._reportAuthenticationError(error);
// Send settings without a token so ComponentProvider can mount and display the error.
this._updateSettings({
dashboardId: undefined,
@@ -562,7 +577,7 @@ export abstract class MetabaseEmbedElement<T extends string[] = string[]>
guestToken: token,
});
} catch (error) {
this.reportAuthenticationError(error);
this._reportAuthenticationError(error);
}
}
@@ -645,20 +660,116 @@ export abstract class MetabaseEmbedElement<T extends string[] = string[]>
return data.jwt;
}
// `parameters` and `sqlParameters` are JS properties whose value
// lives in the element's attribute of the same name. Reading the
// property parses the attribute as JSON; writing the property
// serializes the value back into the attribute. So setting the
// attribute directly (`<metabase-dashboard parameters='{"state":"NY"}'>`),
// assigning the JS property (`el.parameters = ...`).
/**
* Reads the named element attribute and returns it as a plain object.
* Uses the same `parseAttributeValue` parser as `attributeChangedCallback`
* Returns `undefined` if the attribute isn't set, fails to parse,
* or doesn't resolve to a plain object (e.g. a primitive or array).
*/
protected _readJsonAttribute<T>(attributeName: string): T | undefined {
const rawValue = this.getAttribute(attributeName);
if (rawValue === null) {
return undefined;
}
const parsed = parseAttributeValue(rawValue);
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
return undefined;
}
return parsed as T;
}
/**
* Serializes a value as JSON and writes it to the named element
* attribute. `null` or `undefined` removes the attribute. If the
* resulting JSON string equals what's already on the attribute, our
* `attributeChangedCallback` short-circuits on `oldVal === newVal`
* , so we bypass `setAttribute` and dispatch `_updateSettings` directly to
* keep the iframe in sync with the caller's intent.
*/
protected _writeJsonProperty(
settingKey: string,
attrName: string,
value: unknown,
) {
// `null` and `undefined` mean "clear this property"; anything else
// gets serialized as JSON. `nextJson === null` is the marker for
// "the attribute should not exist".
const shouldRemoveValue = value === undefined || value === null;
const nextJson = shouldRemoveValue ? null : JSON.stringify(value);
const currentJson = this.getAttribute(attrName);
if (nextJson === currentJson) {
// Both null: clearing something that was never set. Nothing to do.
if (nextJson === null) {
return;
}
// Same JSON string is already on the attribute, so calling
// `setAttribute` would be a no-op for `attributeChangedCallback`
// (it short-circuits on `oldVal === newVal`, see line ~323). The
// host explicitly assigned the property, so bypass the attribute
// and dispatch `_updateSettings` directly to keep the iframe
// synced with the caller's intent.
this._updateSettings({
[settingKey]: value,
} as Partial<SdkIframeEmbedElementSettings>);
return;
}
// Attribute is being added, removed, or replaced — mutating it
// fires `attributeChangedCallback`, which then calls `_updateSettings`.
if (nextJson === null) {
this.removeAttribute(attrName);
} else {
this.setAttribute(attrName, nextJson);
}
}
}
type ConcreteEmbedElementConstructor<U extends string[]> = new (
...args: any[]
) => MetabaseEmbedElement<U> & {
_componentName: string;
_attributeNames: U;
};
function createCustomElement<
T extends keyof ComponentToAttributes,
U extends (keyof ComponentToAttributes[T] & string)[],
>(componentName: T, attributeNames: U) {
const CustomEmbedElement = class extends MetabaseEmbedElement<U> {
C extends ConcreteEmbedElementConstructor<U> =
ConcreteEmbedElementConstructor<U>,
>(
componentName: T,
attributeNames: U,
decorate?: (Base: ConcreteEmbedElementConstructor<U>) => C,
): C {
const Base = class extends MetabaseEmbedElement<U> {
protected _componentName: string = componentName;
protected _attributeNames: U = attributeNames;
static get observedAttributes() {
return attributeNames;
}
};
} as unknown as ConcreteEmbedElementConstructor<U>;
const CustomEmbedElement = (decorate ? decorate(Base) : Base) as C;
if (typeof window !== "undefined" && !customElements.get(componentName)) {
customElements.define(componentName, CustomEmbedElement);
@@ -667,32 +778,64 @@ function createCustomElement<
return CustomEmbedElement;
}
const MetabaseDashboardElement = createCustomElement("metabase-dashboard", [
"dashboard-id",
"token",
"auto-refresh-interval",
"with-title",
"with-downloads",
"with-subscriptions",
"drills",
"initial-parameters",
"hidden-parameters",
"enable-entity-navigation",
]);
export const MetabaseDashboardElement = createCustomElement(
"metabase-dashboard",
[
"dashboard-id",
"token",
"auto-refresh-interval",
"with-title",
"with-downloads",
"with-subscriptions",
"drills",
"initial-parameters",
"parameters",
"hidden-parameters",
"enable-entity-navigation",
],
(Base) =>
class extends Base {
get parameters(): ParameterValues | undefined {
return this._readJsonAttribute<ParameterValues>("parameters");
}
set parameters(values: ParameterValues | undefined) {
this._writeJsonProperty("parameters", "parameters", values);
}
},
);
export type MetabaseDashboardElement = InstanceType<
typeof MetabaseDashboardElement
>;
const MetabaseQuestionElement = createCustomElement("metabase-question", [
"question-id",
"token",
"with-title",
"with-downloads",
"with-alerts",
"drills",
"initial-sql-parameters",
"hidden-parameters",
"is-save-enabled",
"target-collection",
"entity-types",
]);
export const MetabaseQuestionElement = createCustomElement(
"metabase-question",
[
"question-id",
"token",
"with-title",
"with-downloads",
"with-alerts",
"drills",
"initial-sql-parameters",
"sql-parameters",
"hidden-parameters",
"is-save-enabled",
"target-collection",
"entity-types",
],
(Base) =>
class extends Base {
get sqlParameters(): SqlParameterValues | undefined {
return this._readJsonAttribute<SqlParameterValues>("sql-parameters");
}
set sqlParameters(values: SqlParameterValues | undefined) {
this._writeJsonProperty("sqlParameters", "sql-parameters", values);
}
},
);
export type MetabaseQuestionElement = InstanceType<
typeof MetabaseQuestionElement
>;
const MetabaseManageContentElement = createCustomElement("metabase-browser", [
"initial-collection",
@@ -719,9 +862,4 @@ if (typeof window !== "undefined") {
};
}
export {
MetabaseDashboardElement,
MetabaseQuestionElement,
MetabaseManageContentElement,
MetabaseMetabotElement,
};
export { MetabaseManageContentElement, MetabaseMetabotElement };

View File

@@ -2,12 +2,26 @@ import fetchMock from "fetch-mock";
import jwt from "jsonwebtoken";
import { act } from "react-dom/test-utils";
import { MetabaseEmbedElement } from "./embed";
import {
type MetabaseDashboardElement,
MetabaseEmbedElement,
type MetabaseQuestionElement,
} from "./embed";
const defineMetabaseConfig = (config: unknown) => {
(window as any).metabaseConfig = config;
};
// Wait for all fetch mocks, and async/await to finish
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0));
const triggerIframeMessage = (iframe: HTMLIFrameElement, data: unknown) => {
window.dispatchEvent(
new MessageEvent("message", { data, source: iframe.contentWindow }),
);
};
describe("embed.js script tag for sdk iframe embedding", () => {
let consoleErrorSpy: jest.SpyInstance;
@@ -209,10 +223,6 @@ describe("embed.js script tag for sdk iframe embedding", () => {
});
describe("guest embed token provider", () => {
// Wait for all fetch mocks, and async/await to finish
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0));
function setupGuestEmbed({
dashboardId,
token,
@@ -243,23 +253,18 @@ describe("embed.js script tag for sdk iframe embedding", () => {
"postMessage",
);
const triggerIframeMessage = (data: unknown) => {
window.dispatchEvent(
new MessageEvent("message", { data, source: iframe.contentWindow }),
);
};
return { embed, iframe, iframePostMessageSpy, triggerIframeMessage };
}
describe("refresh token only (static token)", () => {
it("skips provider fetch and sends setSettings directly with static token", async () => {
const { triggerIframeMessage, iframePostMessageSpy } = setupGuestEmbed({
dashboardId: "1",
token: "static-jwt-token",
});
const { iframe, triggerIframeMessage, iframePostMessageSpy } =
setupGuestEmbed({
dashboardId: "1",
token: "static-jwt-token",
});
triggerIframeMessage({ type: "metabase.embed.iframeReady" });
triggerIframeMessage(iframe, { type: "metabase.embed.iframeReady" });
await flushPromises();
expect(fetchMock.callHistory.calls("path:/mock-provider")).toHaveLength(
@@ -289,12 +294,13 @@ describe("embed.js script tag for sdk iframe embedding", () => {
fetchMock.post("path:/mock-provider", { jwt: "refreshed-token" });
const { triggerIframeMessage, iframePostMessageSpy } = setupGuestEmbed({
dashboardId: "1",
customContext: "my-context",
});
const { iframe, triggerIframeMessage, iframePostMessageSpy } =
setupGuestEmbed({
dashboardId: "1",
customContext: "my-context",
});
triggerIframeMessage({
triggerIframeMessage(iframe, {
type: "metabase.embed.requestGuestTokenRefresh",
data: { expiredToken },
});
@@ -323,12 +329,12 @@ describe("embed.js script tag for sdk iframe embedding", () => {
it("should parse a stringified JSON passed to custom-context", async () => {
fetchMock.post("path:/mock-provider", { jwt: "refreshed-token" });
const { triggerIframeMessage } = setupGuestEmbed({
const { iframe, triggerIframeMessage } = setupGuestEmbed({
dashboardId: "1",
customContext: '{"param":"value","nested":{"a":1}}',
});
triggerIframeMessage({
triggerIframeMessage(iframe, {
type: "metabase.embed.requestGuestTokenRefresh",
data: { expiredToken: "any.expired.token" },
});
@@ -348,11 +354,12 @@ describe("embed.js script tag for sdk iframe embedding", () => {
it("sends reportAuthenticationError to iframe when provider returns HTTP error", async () => {
fetchMock.post("path:/mock-provider", 403);
const { triggerIframeMessage, iframePostMessageSpy } = setupGuestEmbed({
dashboardId: "1",
});
const { iframe, triggerIframeMessage, iframePostMessageSpy } =
setupGuestEmbed({
dashboardId: "1",
});
triggerIframeMessage({
triggerIframeMessage(iframe, {
type: "metabase.embed.requestGuestTokenRefresh",
data: { expiredToken: "any.expired.token" },
});
@@ -374,11 +381,12 @@ describe("embed.js script tag for sdk iframe embedding", () => {
it("sends reportAuthenticationError to iframe when provider returns invalid JSON shape", async () => {
fetchMock.post("path:/mock-provider", { token: "wrong-field-name" });
const { triggerIframeMessage, iframePostMessageSpy } = setupGuestEmbed({
dashboardId: "1",
});
const { iframe, triggerIframeMessage, iframePostMessageSpy } =
setupGuestEmbed({
dashboardId: "1",
});
triggerIframeMessage({
triggerIframeMessage(iframe, {
type: "metabase.embed.requestGuestTokenRefresh",
data: { expiredToken: "any.expired.token" },
});
@@ -402,12 +410,13 @@ describe("embed.js script tag for sdk iframe embedding", () => {
it("fetches initial token from provider with { entityType, entityId, customContext } and sends setSettings", async () => {
fetchMock.post("path:/mock-provider", { jwt: "initial-token" });
const { triggerIframeMessage, iframePostMessageSpy } = setupGuestEmbed({
dashboardId: "1",
customContext: "my-context",
});
const { iframe, triggerIframeMessage, iframePostMessageSpy } =
setupGuestEmbed({
dashboardId: "1",
customContext: "my-context",
});
triggerIframeMessage({ type: "metabase.embed.iframeReady" });
triggerIframeMessage(iframe, { type: "metabase.embed.iframeReady" });
await flushPromises();
const call = fetchMock.callHistory.lastCall("path:/mock-provider");
@@ -444,11 +453,12 @@ describe("embed.js script tag for sdk iframe embedding", () => {
it("sends reportAuthenticationError when initial token fetch returns HTTP error", async () => {
fetchMock.post("path:/mock-provider", 500);
const { triggerIframeMessage, iframePostMessageSpy } = setupGuestEmbed({
dashboardId: "1",
});
const { iframe, triggerIframeMessage, iframePostMessageSpy } =
setupGuestEmbed({
dashboardId: "1",
});
triggerIframeMessage({ type: "metabase.embed.iframeReady" });
triggerIframeMessage(iframe, { type: "metabase.embed.iframeReady" });
await flushPromises();
expect(iframePostMessageSpy).toHaveBeenCalledWith(
@@ -479,11 +489,12 @@ describe("embed.js script tag for sdk iframe embedding", () => {
it("sends reportAuthenticationError when initial token fetch returns invalid JSON shape", async () => {
fetchMock.post("path:/mock-provider", { token: "wrong-field" });
const { triggerIframeMessage, iframePostMessageSpy } = setupGuestEmbed({
dashboardId: "1",
});
const { iframe, triggerIframeMessage, iframePostMessageSpy } =
setupGuestEmbed({
dashboardId: "1",
});
triggerIframeMessage({ type: "metabase.embed.iframeReady" });
triggerIframeMessage(iframe, { type: "metabase.embed.iframeReady" });
await flushPromises();
expect(iframePostMessageSpy).toHaveBeenCalledWith(
@@ -500,4 +511,271 @@ describe("embed.js script tag for sdk iframe embedding", () => {
});
});
});
describe("parameter API", () => {
const setupDashboard = () => {
defineMetabaseConfig({ instanceUrl: "http://localhost:3000" });
const embed = document.createElement(
"metabase-dashboard",
) as MetabaseDashboardElement;
embed.setAttribute("dashboard-id", "1");
document.body.appendChild(embed);
const iframe = embed.querySelector("iframe") as HTMLIFrameElement;
const iframePostMessageSpy = jest.spyOn(
iframe.contentWindow!,
"postMessage",
);
const markReady = async () => {
triggerIframeMessage(iframe, { type: "metabase.embed.iframeReady" });
await flushPromises();
iframePostMessageSpy.mockClear();
};
return {
embed,
iframe,
iframePostMessageSpy,
markReady,
};
};
const lastSetSettings = (
spy: jest.SpyInstance,
): Record<string, unknown> | undefined => {
for (let i = spy.mock.calls.length - 1; i >= 0; i--) {
const [message] = spy.mock.calls[i];
if (message?.type === "metabase.embed.setSettings") {
return message.data;
}
}
return undefined;
};
it("pushes a setSettings update when the `parameters` attribute is set", async () => {
const { embed, iframePostMessageSpy, markReady } = setupDashboard();
await markReady();
embed.setAttribute("parameters", JSON.stringify({ state: "NY" }));
const settings = lastSetSettings(iframePostMessageSpy);
expect(settings?.parameters).toEqual({ state: "NY" });
});
it("reflects to the attribute and pushes a setSettings update when the `parameters` property is set", async () => {
const { embed, iframePostMessageSpy, markReady } = setupDashboard();
await markReady();
embed.parameters = { state: "NY" };
expect(embed).toHaveAttribute("parameters", '{"state":"NY"}');
const settings = lastSetSettings(iframePostMessageSpy);
expect(settings?.parameters).toEqual({ state: "NY" });
});
it("returns the parsed attribute when the `parameters` property is read", async () => {
const { embed, markReady } = setupDashboard();
await markReady();
embed.setAttribute("parameters", '{"state":"AR","tier":["gold"]}');
expect(embed.parameters).toEqual({ state: "AR", tier: ["gold"] });
});
it("re-pushes a setSettings update when the same value is set twice in a row", async () => {
const { embed, iframePostMessageSpy, markReady } = setupDashboard();
await markReady();
embed.parameters = { state: null };
iframePostMessageSpy.mockClear();
// Simulates: user manually edited the filter (no host attribute write),
// then pushed the same clear-all payload again. The setter must
// re-dispatch `setSettings` even though `setAttribute` would be a no-op.
embed.parameters = { state: null };
const settings = lastSetSettings(iframePostMessageSpy);
expect(settings?.parameters).toEqual({ state: null });
});
it("pushes a setSettings update when a parameter is cleared via `null`", async () => {
const { embed, iframePostMessageSpy, markReady } = setupDashboard();
await markReady();
embed.parameters = { state: "NY" };
iframePostMessageSpy.mockClear();
embed.parameters = { state: null };
const settings = lastSetSettings(iframePostMessageSpy);
expect(settings?.parameters).toEqual({ state: null });
});
it("removes the attribute when `parameters` is set to undefined", async () => {
const { embed, markReady } = setupDashboard();
await markReady();
embed.parameters = { state: "NY" };
expect(embed.getAttribute("parameters")).not.toBeNull();
embed.parameters = undefined;
expect(embed).not.toHaveAttribute("parameters");
});
it("fires exactly one setSettings call when the `parameters` property is set (no double-dispatch via attributeChangedCallback)", async () => {
const { embed, iframePostMessageSpy, markReady } = setupDashboard();
await markReady();
iframePostMessageSpy.mockClear();
embed.parameters = { state: "NY" };
const setSettingsCalls = iframePostMessageSpy.mock.calls.filter(
([message]) => message?.type === "metabase.embed.setSettings",
);
expect(setSettingsCalls).toHaveLength(1);
expect(setSettingsCalls[0][0].data.parameters).toEqual({ state: "NY" });
});
it("re-pushes setSettings when the same `parameters` value is re-assigned (host can counter iframe drift)", async () => {
const { embed, iframePostMessageSpy, markReady } = setupDashboard();
await markReady();
embed.parameters = { state: "NY" };
iframePostMessageSpy.mockClear();
embed.parameters = { state: "NY" };
const setSettingsCalls = iframePostMessageSpy.mock.calls.filter(
([message]) => message?.type === "metabase.embed.setSettings",
);
expect(setSettingsCalls).toHaveLength(1);
});
it("treats `el.parameters = null` as a no-op (does not crash; no excess setSettings dispatch)", async () => {
const { embed, iframePostMessageSpy, markReady } = setupDashboard();
await markReady();
iframePostMessageSpy.mockClear();
expect(() => {
(embed as unknown as { parameters: unknown }).parameters = null;
}).not.toThrow();
const setSettingsCalls = iframePostMessageSpy.mock.calls.filter(
([message]) => message?.type === "metabase.embed.setSettings",
);
// SDK push hook treats null as "no controlled value" — at the embed
// boundary we don't crash and we don't fire more than one update.
expect(setSettingsCalls.length).toBeLessThanOrEqual(1);
});
it("dispatches a `parameters-change` event when the iframe broadcasts a change", async () => {
const { iframe, embed, markReady } = setupDashboard();
await markReady();
const handler = jest.fn();
embed.addEventListener("parameters-change", handler);
const payload = {
source: "manual-change" as const,
parameters: { state: "NY" },
defaultParameters: {},
lastUsedParameters: {},
};
triggerIframeMessage(iframe, {
type: "metabase.embed.parametersChange",
data: payload,
});
expect(handler).toHaveBeenCalledTimes(1);
const event = handler.mock.calls[0][0] as CustomEvent;
expect(event.detail).toEqual(payload);
});
});
describe("SQL parameter API", () => {
const setupQuestion = () => {
defineMetabaseConfig({ instanceUrl: "http://localhost:3000" });
const embed = document.createElement(
"metabase-question",
) as MetabaseQuestionElement;
embed.setAttribute("question-id", "1");
document.body.appendChild(embed);
const iframe = embed.querySelector("iframe") as HTMLIFrameElement;
const iframePostMessageSpy = jest.spyOn(
iframe.contentWindow!,
"postMessage",
);
const markReady = async () => {
triggerIframeMessage(iframe, { type: "metabase.embed.iframeReady" });
await flushPromises();
iframePostMessageSpy.mockClear();
};
return {
embed,
iframe,
iframePostMessageSpy,
markReady,
};
};
const lastSetSettings = (
spy: jest.SpyInstance,
): Record<string, unknown> | undefined => {
for (let i = spy.mock.calls.length - 1; i >= 0; i--) {
const [message] = spy.mock.calls[i];
if (message?.type === "metabase.embed.setSettings") {
return message.data;
}
}
return undefined;
};
it("pushes a setSettings update when the `sql-parameters` attribute is set", async () => {
const { embed, iframePostMessageSpy, markReady } = setupQuestion();
await markReady();
embed.setAttribute("sql-parameters", JSON.stringify({ region: "EU" }));
const settings = lastSetSettings(iframePostMessageSpy);
expect(settings?.sqlParameters).toEqual({ region: "EU" });
});
it("reflects to the attribute and pushes a setSettings update when the `sqlParameters` property is set", async () => {
const { embed, iframePostMessageSpy, markReady } = setupQuestion();
await markReady();
embed.sqlParameters = { region: "EU" };
expect(embed).toHaveAttribute("sql-parameters", '{"region":"EU"}');
const settings = lastSetSettings(iframePostMessageSpy);
expect(settings?.sqlParameters).toEqual({ region: "EU" });
});
it("dispatches a `sql-parameters-change` event on iframe broadcast", async () => {
const { iframe, embed, markReady } = setupQuestion();
await markReady();
const handler = jest.fn();
embed.addEventListener("sql-parameters-change", handler);
const payload = {
source: "manual-change" as const,
parameters: { region: "EU" },
defaultParameters: {},
};
triggerIframeMessage(iframe, {
type: "metabase.embed.sqlParametersChange",
data: payload,
});
expect(handler).toHaveBeenCalledTimes(1);
const event = handler.mock.calls[0][0] as CustomEvent;
expect(event.detail).toEqual(payload);
});
});
});

View File

@@ -21,7 +21,7 @@ type UsageAnalytics = {
embedHostUrl: string;
};
const sendMessage = (message: SdkIframeEmbedTagMessage) => {
export const sendMessage = (message: SdkIframeEmbedTagMessage) => {
window.parent.postMessage(message, "*");
};

View File

@@ -7,6 +7,8 @@ import type {
MetabaseTheme,
SqlParameterValues,
} from "embedding-sdk-bundle/types";
import type { ParameterChangePayload } from "embedding-sdk-bundle/types/dashboard";
import type { SqlParameterChangePayload } from "embedding-sdk-bundle/types/question";
import type {
SdkIframeDashboardEmbedSettings,
SdkIframeQuestionEmbedSettings,
@@ -22,8 +24,12 @@ import type { EmbeddedAnalyticsJsEventSchema } from "metabase-types/analytics/em
import type { CollectionId } from "metabase-types/api";
import type { EntityToken } from "metabase-types/api/entity";
/** Events that the embed.js script listens for */
/** Events that the embed.js script listens for. */
export type SdkIframeEmbedTagMessage =
| SdkIframeEmbedTagTransportMessage
| SdkIframeEmbedComponentTagMessage;
export type SdkIframeEmbedTagTransportMessage =
| SdkIframeEmbedTagIframeReadyMessage
| SdkIframeEmbedTagRequestSessionTokenMessage
| SdkIframeEmbedTagHandleLinkMessage
@@ -46,7 +52,6 @@ export type SdkIframeEmbedTagRequestGuestTokenRefreshMessage = {
};
};
/** Events that the sdk embed route listens for */
export type SdkIframeEmbedMessage =
| SdkIframeEmbedSetSettingsMessage
| SdkIframeEmbedSubmitSessionTokenMessage
@@ -90,6 +95,16 @@ export type SdkIframeEmbedSubmitRefreshedGuestTokenMessage = {
};
};
export type SdkIframeEmbedComponentTagMessage =
| {
type: "metabase.embed.parametersChange";
data: ParameterChangePayload;
}
| {
type: "metabase.embed.sqlParametersChange";
data: SqlParameterChangePayload;
};
// --- Embed Option Interfaces ---
export type DashboardEmbedOptions = StrictUnion<
@@ -105,6 +120,7 @@ export type DashboardEmbedOptions = StrictUnion<
// parameters
initialParameters?: ParameterValues;
parameters?: ParameterValues;
hiddenParameters?: string[];
enableEntityNavigation?: boolean;
@@ -130,6 +146,7 @@ export type QuestionEmbedOptions = StrictUnion<
// parameters
initialSqlParameters?: SqlParameterValues;
sqlParameters?: SqlParameterValues;
hiddenParameters?: string[];
customContext?: string | Record<string, unknown>;

View File

@@ -75,6 +75,16 @@ export interface MetabaseDashboardAttributes {
*/
"initial-parameters"?: object;
/**
* Controlled dashboard filters values, e.g. `{ 'productId': '42' }`.
* Setting this attribute supersedes `initial-parameters` as the seed
* and stays in sync with subsequent mutations. Pair with the
* `parameters-change` DOM event to track edits.
*
* @remarks Pro/Enterprise, Guest embed
*/
parameters?: object;
/**
* List of filter names to hide from the dashboard, e.g. `['productId']`.
*
@@ -163,6 +173,16 @@ export interface MetabaseQuestionAttributes {
*/
"initial-sql-parameters"?: object;
/**
* Controlled SQL parameter values, e.g. `{ "productId": "42" }`. Setting
* this attribute supersedes `initial-sql-parameters` as the seed and
* stays in sync with subsequent mutations. Pair with the
* `sql-parameters-change` DOM event to track edits.
*
* @remarks Pro/Enterprise, Guest embed
*/
"sql-parameters"?: object;
/**
* List of parameter names to hide from the question.
*

View File

@@ -78,7 +78,5 @@ export { Databases as databases } from "./databases";
export { Schemas as schemas } from "./schemas";
export { Tables as tables } from "./tables";
export { Fields as fields } from "./fields";
export { Segments as segments } from "./segments";
export { Measures as measures } from "./measures";
export { Snippets as snippets } from "./snippets";

View File

@@ -1,61 +0,0 @@
import {
measureApi,
useGetMeasureQuery,
useListMeasuresQuery,
} from "metabase/api";
import { MeasureSchema } from "metabase/schema";
import { getMetadata } from "metabase/selectors/metadata";
import { createEntity, entityCompatibleQuery } from "./utils";
/**
* @deprecated use "metabase/api" instead
*/
export const Measures = createEntity({
name: "measures",
nameOne: "measure",
path: "/api/measure",
schema: MeasureSchema,
rtk: () => ({
getUseGetQuery: () => ({
useGetQuery,
}),
useListQuery: useListMeasuresQuery,
}),
api: {
list: (entityQuery, dispatch) =>
entityCompatibleQuery(
entityQuery,
dispatch,
measureApi.endpoints.listMeasures,
),
get: (entityQuery, options, dispatch) =>
entityCompatibleQuery(
entityQuery.id,
dispatch,
measureApi.endpoints.getMeasure,
),
create: (entityQuery, dispatch) =>
entityCompatibleQuery(
entityQuery,
dispatch,
measureApi.endpoints.createMeasure,
),
update: (entityQuery, dispatch) =>
entityCompatibleQuery(
entityQuery,
dispatch,
measureApi.endpoints.updateMeasure,
),
},
selectors: {
getObject: (state, { entityId }) => getMetadata(state).measure(entityId),
},
});
const useGetQuery = ({ id }, options) => {
return useGetMeasureQuery(id, options);
};

View File

@@ -1,66 +0,0 @@
import {
segmentApi,
useGetSegmentQuery,
useListSegmentsQuery,
} from "metabase/api";
import { SegmentSchema } from "metabase/schema";
import { getMetadata } from "metabase/selectors/metadata";
import { createEntity, entityCompatibleQuery } from "./utils";
/**
* @deprecated use "metabase/api" instead
*/
export const Segments = createEntity({
name: "segments",
nameOne: "segment",
path: "/api/segment",
schema: SegmentSchema,
rtk: () => ({
getUseGetQuery: () => ({
useGetQuery,
}),
useListQuery: useListSegmentsQuery,
}),
api: {
list: (entityQuery, dispatch) =>
entityCompatibleQuery(
entityQuery,
dispatch,
segmentApi.endpoints.listSegments,
),
get: (entityQuery, options, dispatch) =>
entityCompatibleQuery(
entityQuery.id,
dispatch,
segmentApi.endpoints.getSegment,
),
create: (entityQuery, dispatch) =>
entityCompatibleQuery(
entityQuery,
dispatch,
segmentApi.endpoints.createSegment,
),
update: (entityQuery, dispatch) =>
entityCompatibleQuery(
entityQuery,
dispatch,
segmentApi.endpoints.updateSegment,
),
},
objectActions: {
// NOTE: DELETE not currently implemented
delete: null,
},
selectors: {
getObject: (state, { entityId }) => getMetadata(state).segment(entityId),
},
});
const useGetQuery = ({ id }, options) => {
return useGetSegmentQuery(id, options);
};

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