Compare commits
164 Commits
hackathon-
...
persistent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b7c5bf4d | ||
|
|
392a3dbe3d | ||
|
|
7c34ef9b02 | ||
|
|
6eae581864 | ||
|
|
8ed89e869e | ||
|
|
549d278492 | ||
|
|
74770efa10 | ||
|
|
0dc06ff1e3 | ||
|
|
9ee6dac54a | ||
|
|
68cd362ae6 | ||
|
|
f084b2e557 | ||
|
|
840127211b | ||
|
|
4e7692960f | ||
|
|
7fc65ddb6c | ||
|
|
8ff89b543c | ||
|
|
2bb669708f | ||
|
|
daa02967fa | ||
|
|
524e6bf086 | ||
|
|
21aeddfa56 | ||
|
|
ecd77df06b | ||
|
|
5ad4b679e8 | ||
|
|
7437cf3703 | ||
|
|
5b0b0ac624 | ||
|
|
215ee8dc2b | ||
|
|
445c6359e3 | ||
|
|
4e69f21aa3 | ||
|
|
ce13123024 | ||
|
|
cb91c1125a | ||
|
|
d1a1c68e15 | ||
|
|
1da83bd51b | ||
|
|
3728dbb5a5 | ||
|
|
ba912fe646 | ||
|
|
c3110721b7 | ||
|
|
e851dc76ed | ||
|
|
30033828b8 | ||
|
|
a4a8bcc058 | ||
|
|
e8640a399a | ||
|
|
e3cb29170e | ||
|
|
fea5506519 | ||
|
|
582ff189f7 | ||
|
|
358d7eec28 | ||
|
|
dff14540d2 | ||
|
|
8103ca8a48 | ||
|
|
8282cee525 | ||
|
|
618831d368 | ||
|
|
81e055c0cd | ||
|
|
a5915a33b4 | ||
|
|
fdea81a6bb | ||
|
|
6fad3ef45c | ||
|
|
5ad741c0d2 | ||
|
|
69570bc65f | ||
|
|
b719615aa6 | ||
|
|
afcc49f6f6 | ||
|
|
d12184f4a0 | ||
|
|
bde8a43aeb | ||
|
|
76f8cb6ce3 | ||
|
|
31840a1a31 | ||
|
|
a5968cdf24 | ||
|
|
5bcdea9b51 | ||
|
|
8eafca6c74 | ||
|
|
6adf7f2ef8 | ||
|
|
2ea7960d5c | ||
|
|
66af12b9cd | ||
|
|
8171f24196 | ||
|
|
a86dffa344 | ||
|
|
db09e70823 | ||
|
|
407f3bc2df | ||
|
|
5b1181eba5 | ||
|
|
1574a28b4f | ||
|
|
ace1e034a2 | ||
|
|
9142569d91 | ||
|
|
30c6275dc5 | ||
|
|
0f2e7ea906 | ||
|
|
cff57cbbb5 | ||
|
|
62c13bb028 | ||
|
|
8f7064e87f | ||
|
|
27e5f298d8 | ||
|
|
c6dff735a9 | ||
|
|
daaca9ef62 | ||
|
|
ad9b980fef | ||
|
|
96a308a64e | ||
|
|
3c24edbf47 | ||
|
|
93b375bbe2 | ||
|
|
ca3a2ea136 | ||
|
|
e9f6c93013 | ||
|
|
89786605c1 | ||
|
|
82f5dea54d | ||
|
|
ad62aed71a | ||
|
|
3ab76adf8d | ||
|
|
1f019c1e10 | ||
|
|
3e4335a798 | ||
|
|
70967125aa | ||
|
|
5921542894 | ||
|
|
8c572f77f4 | ||
|
|
7e5bb21c3d | ||
|
|
2c8c0e30aa | ||
|
|
4ca03e17ef | ||
|
|
ec2b0f38ae | ||
|
|
2bad85564e | ||
|
|
d1252c7e8e | ||
|
|
4a7059cac7 | ||
|
|
9bd22bf4b1 | ||
|
|
0462cde56d | ||
|
|
e99312fe64 | ||
|
|
d8a7cd3501 | ||
|
|
2d2c4b35a5 | ||
|
|
9e717a6056 | ||
|
|
9ed603e6ce | ||
|
|
8a5545ad32 | ||
|
|
82b89e31fb | ||
|
|
607985e054 | ||
|
|
a2e43e1996 | ||
|
|
3b96cd4619 | ||
|
|
31790b6052 | ||
|
|
846f38d443 | ||
|
|
e7a9e8c100 | ||
|
|
e1b7b8e912 | ||
|
|
2480ce058b | ||
|
|
cf04ba48c2 | ||
|
|
8a853116bf | ||
|
|
c54c9ded12 | ||
|
|
0fc1a4b8c9 | ||
|
|
6ca5625902 | ||
|
|
a4fdcdef64 | ||
|
|
b1fe71ca2f | ||
|
|
4daf2cb8a2 | ||
|
|
4a3d739318 | ||
|
|
ba85c0513c | ||
|
|
b8c26e3c0c | ||
|
|
21b5b3e93a | ||
|
|
0a1a13b48b | ||
|
|
a18f7a94e2 | ||
|
|
067626c71d | ||
|
|
3bc10ee2e1 | ||
|
|
b9fba6ab7a | ||
|
|
20e5611810 | ||
|
|
37d68bca75 | ||
|
|
e58e0b7f80 | ||
|
|
81aab7b8ca | ||
|
|
bcf8a422f6 | ||
|
|
ac434a50aa | ||
|
|
4f050fba0a | ||
|
|
c36a72146e | ||
|
|
d82ec362ba | ||
|
|
c2172311f5 | ||
|
|
5a7bcfab93 | ||
|
|
decbc82b1a | ||
|
|
bd0f5b3b69 | ||
|
|
7f98271a59 | ||
|
|
8cc219994d | ||
|
|
84168dbf05 | ||
|
|
d6da219a70 | ||
|
|
65426fe653 | ||
|
|
6403d80fea | ||
|
|
bbcba1500c | ||
|
|
fcf4dbb6e8 | ||
|
|
3d75754ad8 | ||
|
|
af72309d8e | ||
|
|
646b4c4548 | ||
|
|
92ada9186c | ||
|
|
e67ce4582e | ||
|
|
25e3afcdbb | ||
|
|
b31a9a09c5 | ||
|
|
50614c15aa |
@@ -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*
|
||||
|
||||
@@ -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
|
||||
|
||||
24
.clj-kondo/src/hooks/metabase/mq/impl.clj
Normal file
24
.clj-kondo/src/hooks/metabase/mq/impl.clj
Normal 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}))
|
||||
62
.github/workflows/backend.yml
vendored
62
.github/workflows/backend.yml
vendored
@@ -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
|
||||
|
||||
6
deps.edn
6
deps.edn
@@ -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
870
dev/src/dev/mq_perf.clj
Normal 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
104
dev/src/dev/mq_perf_multi.sh
Executable 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
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
175
docs/embedding/parameters.md
Normal file
175
docs/embedding/parameters.md
Normal 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" %}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))))))))))
|
||||
|
||||
@@ -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]]})))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("branding settings", () => {
|
||||
"Logo",
|
||||
"Font",
|
||||
"Favicon",
|
||||
"Loading message",
|
||||
];
|
||||
|
||||
it.each(brandingSettings)("renders %s", async (setting) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.
|
||||
**/
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? {},
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
297
frontend/src/embedding-sdk-bundle/test/ParametersPlayground.tsx
Normal file
297
frontend/src/embedding-sdk-bundle/test/ParametersPlayground.tsx
Normal 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'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 "Get now" 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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/ }),
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./EntityPicker";
|
||||
export { AddToDashSelectDashModal } from "./AddToDashSelectDashModal";
|
||||
export { CollectionPickerModal } from "./CollectionPicker";
|
||||
export { DashboardPickerModal } from "./DashboardPicker";
|
||||
export { DataPickerModal } from "./DataPicker";
|
||||
|
||||
@@ -825,6 +825,7 @@ export const fetchDashboard = createAsyncThunk(
|
||||
result.param_fields,
|
||||
metadata,
|
||||
);
|
||||
|
||||
const parameterValuesById = preserveParameters
|
||||
? getParameterValues(getState())
|
||||
: getParameterValuesByIdFromQueryParams(
|
||||
|
||||
@@ -39,6 +39,6 @@
|
||||
.MoveParameterMenuOption[aria-selected="true"] {
|
||||
.MoveParameterMenuOptionIcon,
|
||||
.MoveParameterMenuOptionText {
|
||||
color: var(--mb-color-text-primary-inverse);
|
||||
color: var(--mb-color-text-selected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 })}</>;
|
||||
};
|
||||
@@ -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 })}</>;
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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)[],
|
||||
|
||||
@@ -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'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 />,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ type UsageAnalytics = {
|
||||
embedHostUrl: string;
|
||||
};
|
||||
|
||||
const sendMessage = (message: SdkIframeEmbedTagMessage) => {
|
||||
export const sendMessage = (message: SdkIframeEmbedTagMessage) => {
|
||||
window.parent.postMessage(message, "*");
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user