Compare commits

...

114 Commits

Author SHA1 Message Date
Ngoc Khuat
64977bffa6 just tag to clone 2025-10-03 16:10:22 +07:00
Ngoc Khuat
25dddd8f32 using the correct port 2025-10-03 15:31:02 +07:00
Ngoc Khuat
ee806be8b7 we forgot the localstack 2025-10-03 15:10:49 +07:00
Ngoc Khuat
0ea6e387c0 it's 5001 2025-10-03 15:00:31 +07:00
Ngoc Khuat
f6f128b87d use docker with pinned version for our CI 2025-10-03 14:48:47 +07:00
Phoomparin Mano
8cb9ca883d Change embed flow tracking to track step completions for funnel analysis (#63973) 2025-10-03 12:55:01 +07:00
Phoomparin Mano
384d0b5d3b Update alignment and copy for embedding hub (#64279) 2025-10-03 12:06:24 +07:00
Tyler Plude
1561702bdb Return error string in response when model ID is a question instead of 404 (#64342) 2025-10-02 17:25:51 -07:00
Riley Thompson
4d0eef7217 Store transform create/updates in revision table (#64247)
* store transform revisions

* Add tests

* remove unneeded arity

* fix whitespace

* include impl.transform in revisions.init

* move transform test to enterprise folder

* use =? and check unneeded keys were removed

* revert =? use

* dissoc source from transform comparison
2025-10-02 18:40:59 -04:00
Tyler Plude
cc6827ae8c [METABOT] Use limit in final search response (#64336)
* Limit default 50 search results

* tweak
2025-10-02 14:20:26 -07:00
ignacio
08dffba554 minimal change to dashboard caching steps (#64052)
* minimal change

* whoops

* whoops

* Update docs/configuring-metabase/caching.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update docs/configuring-metabase/caching.md

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Alex Yarosh <alexandra@metabase.com>
2025-10-02 21:12:18 +00:00
Jeff Bruemmer
18b59c1b4d docs - notes on Metabot feedback (#64307)
* notes on feedback

* edit

* link
2025-10-02 17:02:18 -04:00
Anderson
d56be957f0 Show 'See it' link next to 'Saved' button after x-ray is saved as dashboard (#64265)
* Show 'See it' link next to 'Saved' button after x-ray is saved as dashboard

* Use Flex and Group mantine components

* use cy.log instead of JS comments

Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>

* reverse the order of e2e assertions/actions

Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>

* Fix tests

---------

Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>
2025-10-02 17:09:05 -03:00
Edward Paget
6631ed5389 chore: Remove unused column-level middleware (#64326)
* chore: Remove unused column-level middleware

* fixup! update docstring
2025-10-02 19:55:30 +00:00
Noah Moss
16c4a16025 Fix edge case in to-tsquery-expr (#64330) 2025-10-02 19:15:21 +00:00
Edward Paget
2219786d93 fix: validate ldap server email before save (#62616)
Prevents the user from getting an ugly assertion error on login if the
ldap is misconfigured so we can't read a real email from the server response.
2025-10-02 18:46:33 +00:00
Phoomparin Mano
93052b8a8e Hide the step number circle in the setup flow when we only have one step (#64292) 2025-10-03 01:14:00 +07:00
Sébastien
e01cbfc87e Closes EMB-854 escape single quotes in embed code snippets (#64242)
* Closes EMB-854 escape single quotes in embed code snippets

* .

* .

* cleanup
2025-10-02 15:54:37 +02:00
Oleksandr Solovyov
899d6edb3c Mage run tests via nrepl (#64236) 2025-10-02 16:48:59 +03:00
Kamil Mielnik
5705ba7f81 Move SmartScalar subcomponents to separate files (#64283) 2025-10-02 19:44:59 +07:00
Cam Saul
0261a15e7b QP result metadata should always populate :lib/desired-column-alias (#64063)
* QP result metadata should always populate `:lib/desired-column-alias`

* Don't truncate desired column aliases for Table returned columns
2025-10-02 09:03:54 +00:00
Mahatthana (Kelvin) Nomsawadi
02cec9f38d fix(sdk): Allow token withoutexp, but raise a warning (#64238)
* Allow token without exp, but raise a warning

* Fix typing

* Fix wrong doc link anchor

* Fix doc link accessible label

* Add a test

* Fix sso endpoint calling repeating when jwt doesn't contain `exp`

* Fix unit tests

* Fix test for real!

* Fix test name typo

Co-authored-by: Nicolò Pretto <info@npretto.com>

* Use correct terminology "session"

Co-authored-by: Nicolò Pretto <info@npretto.com>

* Fix showing no EXP claim warning when the token is being fetched

Co-authored-by: Nicolò Pretto <info@npretto.com>

---------

Co-authored-by: Nicolò Pretto <info@npretto.com>
2025-10-02 10:01:57 +01:00
Cam Saul
8fa9204d57 Fix formatting for native-query (#64291) 2025-10-02 01:43:18 -07:00
Nicolò Pretto
88229c321e fix(modular): links and type/URL columns should be rendered as clickable (#64243)
links
2025-10-02 10:19:43 +02:00
Timofey Kachalov
4d0d576c13 feat(sdk-bundle): Add SqlParametersList component for InteractiveQuestion and StaticQuestion (#64187) 2025-10-02 10:24:54 +04:00
Timofey Kachalov
7e4dfde94f Adjust sample apps setup (#64274) 2025-10-02 08:59:05 +04:00
William
85fc7cb3fd Dependencies tracking (#63248) 2025-10-01 22:37:16 -04:00
dpsutton
16dc5bece0 Log errors in downgrades (#64309)
Fixes https://github.com/metabase/metabase/issues/62179

Before (and bad) (wordwrap introduced for clarity):
```
2025-08-14 16:17:36,662 WARN app-db.liquibase :: The following
changesets were not rolled back. Likely because they are not in the
changelog file: v56.2025-06-05T16:48:48, v56.2025-06-06T20:11:53,
v56.2025-06-06T20:11:54, v56.2025-06-06T20:11:55,
v56.2025-06-06T20:11:56, v56.2025-06-06T20:11:58,
v56.2025-06-13T15:00:00, v56.2025-07-07T08:02:43,
v56.2025-07-17T20:11:55, v56.2025-07-17T20:11:56 2025-08-14 16:17:36,662
INFO liquibase.logging :: Successfully released change log lock ```
```

And now:

```
2025-10-01 16:00:19,326 INFO app-db.liquibase :: Rolling back app database schema to version 55
2025-10-01 16:00:19,326 INFO app-db.liquibase :: Going to roll back changeset v56.2025-07-17T20:11:56
Rolling Back Changeset: migrations/056_update_migrations.yaml::v56.2025-07-17T20:11:56::edpaget
2025-10-01 16:00:19,328 ERROR app-db.liquibase :: Error rolling back migration v56.2025-07-17T20:11:56
liquibase.exception.RollbackFailedException: liquibase.exception.DatabaseException: ERROR: column "jwt_attributes" does not exist
  Position: 41 [Failed SQL: (0) UPDATE core_user SET login_attributes = jwt_attributes WHERE sso_source = 'jwt']
  	at liquibase.changelog.ChangeSet.rollback(ChangeSet.java:1041)
        ...
Caused by: liquibase.exception.DatabaseException: ERROR: column "jwt_attributes" does not exist
  Position: 41 [Failed SQL: (0) UPDATE core_user SET login_attributes = jwt_attributes WHERE sso_source = 'jwt']
	at liquibase.executor.jvm.JdbcExecutor$ExecuteStatementCallback.doInStatement(JdbcExecutor.java:520)
       ... omitted
       ... 65 more
Caused by: org.postgresql.util.PSQLException: ERROR: column "jwt_attributes" does not exist
  Position: 41
	at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2734)
	... 70 more

2025-10-01 16:00:19,330 WARN app-db.liquibase :: The following
changesets were not rolled back. Likely because there were errors in
rollback (v56.2025-07-17T20:11:56): v56.2025-06-06T20:11:53,
v56.2025-06-06T20:11:54, v56.2025-06-06T20:11:55,
v56.2025-06-06T20:11:56, v56.2025-06-06T20:11:58,
v56.2025-06-13T15:00:00, v56.2025-07-07T08:02:43,
v56.2025-07-17T20:11:55, v56.2025-07-17T20:11:56 2025-10-01 16:00:19,330
INFO liquibase.logging :: Successfully released change log lock
```

How i recreated:
connected to a jar:
```
MB_DB_CONNECTION_URI="postgres://dan:password@localhost:5432/downgrade-checks" MB_JETTY_PORT=2999 java "$(socket-repl 6000)" -jar $JARS/1.56.6.2.jar
```

```sql
downgrade-checks=# alter table core_user drop column jwt_attributes;
ALTER TABLE
```

this will make a downgrade fail:
```yaml
  - changeSet:
      id: v56.2025-07-17T20:11:56
      author: edpaget
      comment: |
        Add sync login_attributes with jwt_attributes, we need to replace jwt_attributes
        with login attributes for users with sso_source jwt so that they will get the new
        behavior where login_attributes can override jwt_attributes
      changes:
        - sql: >-
           UPDATE core_user
           SET jwt_attributes = login_attributes, login_attributes = '{}'
           WHERE sso_source = 'jwt';
      rollback:
        - sql: >-
           UPDATE core_user
           SET login_attributes = jwt_attributes
           WHERE sso_source = 'jwt';
```

This will fail as the `jwt_attributes` column has been removed.

```
Error rolling back migration v56.2025-07-17T20:11:56
liquibase.exception.RollbackFailedException: liquibase.exception.DatabaseException: ERROR: column "jwt_attributes" does not exist
  Position: 41 [Failed SQL: (0) UPDATE core_user SET login_attributes = jwt_attributes WHERE sso_source = 'jwt']
```

And then connect to the jar again, redefine the changes here, and run

```clojure
  (let [data-source (metabase.app-db.core/data-source)]
    (with-open [conn (.getConnection ^javax.sql.DataSource data-source)]
      (with-liquibase [liquibase conn]
        (rollback-major-version! conn liquibase false))))
```
2025-10-01 17:59:50 -05:00
Nathan Voxland
3f15dcd0ad Better error handling when sync-tracking-atoms fails (#64132) 2025-10-01 16:42:11 -05:00
Phoomparin Mano
6e17e96bf0 feat(sdk-bundle): stacked and sidebar layouts for metabot questions (#64142) 2025-10-02 04:03:24 +07:00
Fraser
59dc6a1376 Remove skipped dashboard toast e2e tests (#64266) 2025-10-02 09:36:51 +13:00
John Swanson
4614a37972 Fix u/slugify function's max-length option (#64256)
* Fix `u/slugify` function's max-length option

`u/slugify` url-encoded non-ASCII characters, then took `max-length`
chars off the result.

Both make sense in isolation - but if we're slugifying, e.g., the string
勇士, with max-length 10, this results in:

- first, url encode non-ASCII characters => "%E5%8B%87%E5%A3%AB"
- then, take 10 characters => "%E5%8B%87%"

But a bare `%` character, as in the trailing character above, is not
valid URI syntax.

Discovered in [UXW-666: Users can't download results for questions with
long cyrillic names, error "URL
malformed"](https://linear.app/metabase/issue/UXW-666/users-cant-download-results-for-questions-with-long-cyrillic-names)
but there are probably more cases where this has caused mischief.

* fix test
2025-10-01 11:53:46 -07:00
Alex Yarosh
648039ca1a docs: don't overhype interactive embedding (#64299)
* docs: don't overhype interactive embedding

* Add in-page promo for embedding workshop

Added a promotional section for the embedding workshop.

* Update docs/embedding/interactive-embedding.md

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

---------

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2025-10-01 18:46:48 +00:00
Jeff Bruemmer
2804f26f4b fix placeholder (#64240) 2025-10-01 14:30:33 -04:00
Alex Yarosh
158b78fb25 docs: clickhouse cloud (#64296) 2025-10-01 17:49:45 +00:00
Rodrigo López Dato
9b82a4d92d Add explicit import/export direction to serialization errors (#63777) 2025-10-01 20:30:33 +03:00
dpsutton
5fb41fd3b1 lower log level to debug for field ref issues (#64281)
These log lines will now be debug level warnings rather warning level warnings.
```
<lib.normalize>  Error normalizing MBQL 5
<lib.field.resolution>  We tried every trick we could think
<lib.join>  Failed to find matching column in join
```

Intrepid readers will also notice that moved "Resolved field %s to an
expression. ..." to debug as well.
2025-10-01 11:07:06 -05:00
Rodrigo López Dato
db70faad90 Show full timestamp for revision history entries on hover (#63256) 2025-10-01 15:07:42 +00:00
Alex Yarosh
dddf9d679c docs: serializing new entities (#64284)
Added document comments to the list of non-exported entities.
2025-10-01 10:26:54 -04:00
Uladzimir Havenchyk
c6c0bf73dd tests: wait for data in a flaky test (#64267) 2025-10-01 16:13:00 +03:00
John Swanson
c5da59566e Fix downloading tables grouped by datetime->int (#64245)
* Fix downloading tables grouped by datetime->int

The Linear task has a slightly more involved setup. I found that I could
replicate the issue with a slightly simpler setup:

- set up a simple query:
  - select COUNT from ORDERS
  - group by the day of the month
- log in as the sandboxed user
- try to download the card results (as CSV, XLSX, etc)
- see the error

Why does this happen? I found that, for a sandboxed user, the query
results show the "day of the month" column as having

```
         :base_type :type/DateTime,
         :database_type "TIMESTAMP",
         :effective_type :type/Integer,
```

while an admin user sees the same column as having

```
         :base_type :type/Integer,
         :database_type "INTEGER",
         :effective_type :type/Integer,
```

This seems bizarre and worth looking more into, but honestly shouldn't
really matter - if the effective type is an integer, we certainly
shouldn't be formatting it as a datetime.

`field-is-type?` is only used for checking whether a field is temporal
for formatting purposes, so I think this is a relatively low risk
change, and note that `metabase.lib.types.isa/temporal?` (the
replacement suggested by the deprecation notice for
`metabase.types.core/temporal-field?`) returns `false` for both
cases.

[UXW-660: Pivot tables with integer breakout columns fail to download as formatted XLSX for sandboxed tables](https://linear.app/metabase/issue/UXW-660/pivot-tables-with-integer-breakout-columns-fail-to-download-as)

* Write a test
2025-10-01 05:37:26 -07:00
Kamil Mielnik
f914bf8714 Update table editing e2e tests to not add or remove rows in Sample Database (#64273)
* Update the row deleting test to use writable db

* Update the row adding test to use writable db
2025-10-01 06:16:52 -05:00
Ngoc Khuat
448e9c8278 hide the python runner settings if hosted (#64233) 2025-10-01 08:18:44 +00:00
Nicola Mometto
0ac7bce92b Python runner timeouts (#64095)
* add python runner timeout as setting

* set timeout status

* add timeout status test

* appease kondo

* proper script

* Fix if: expr in upload runner logs

* Fix python runner log name

* Remove setting qualifier

* Add python-runner-timeout-seconds to FE settings type

* Ensure premium feature enabled so setting can be changed

---------

Co-authored-by: Dan Stone <wotbrew@gmail.com>
2025-10-01 09:15:08 +01:00
Romeo Van Snick
ccd8a11ee7 Add context to Save verb and fix similar translation issues (#64077)
* Add context to Save verb

* Add key

* Extract button from nested t

* Find other instances of nested c(...).jt`...${<x>...</x>}...` calls and unnest them
2025-10-01 17:58:46 +10:00
Ngoc Khuat
5164a08b2a Do not run transform test in postgres mysql (#64241) 2025-10-01 04:24:04 +00:00
Romeo Van Snick
e50317d86d Avoid extra interval with single interval in Bar chart (#64175)
* Remove extra interval tick when there is only one interval

* Add repro for metabase#63671

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2025-10-01 13:31:05 +10:00
Nathan Voxland
3800f7452a Support dbname for redshift in config files (#64069) 2025-09-30 22:21:49 -05:00
Romeo Van Snick
f89073a6bd Do not show error when loading (#64082)
* Pass isRunning to Visualization

* Hide error when query is running

* Add unit test for metabase#63410
2025-10-01 13:05:11 +10:00
Mahatthana (Kelvin) Nomsawadi
d848a88e64 Fix can't drill SQL questions in embedding SDK (#64083)
* Fix can't drill SQL questions in embedding SDK

* Add tests

* Fix the audit DB should not be able to see SQL

* Fix every CI check failing from updating from master

* Address code feedback, make it more clean

* Fix transducer order
2025-10-01 03:10:46 +01:00
Fraser
356025f41b Unskip issue 42049 reproduction (#63862) 2025-10-01 12:32:49 +13:00
Fraser
a0c2ebd9e9 Delete test for non-existent feature (#63683) 2025-10-01 09:51:40 +13:00
Alex Yarosh
c9bc786c60 remove actions from embedding overview (#64249)
Updated the requirement note for embedding types to clarify that a Pro and Enterprise plan is necessary.
2025-09-30 16:30:50 -04:00
Ramiro Nuñez Dosio
1f024e148d Revise README for clarity and updated links (#64194)
* Revise README for clarity and updated links

Update readme with 

- Product image (TBC which image)
- Update links to latest version (no redirects)
- Add mention of new features (Metabot AI and React SDK)
- Add community section

* Update README.md

Co-authored-by: alexandermkinn <85193003+alexandermkinn@users.noreply.github.com>

* Update README.md

Co-authored-by: alexandermkinn <85193003+alexandermkinn@users.noreply.github.com>

* Update Metabase product screenshot URL

Updated the product screenshot link in the README.

* Update README.md

Co-authored-by: Jeff Bruemmer <jeffbruemmer@gmail.com>

* Update README.md

Co-authored-by: Jeff Bruemmer <jeffbruemmer@gmail.com>

---------

Co-authored-by: alexandermkinn <85193003+alexandermkinn@users.noreply.github.com>
Co-authored-by: Jeff Bruemmer <jeffbruemmer@gmail.com>
2025-09-30 16:05:51 -04:00
Alex Yarosh
7c8a53c4d5 docs: pin shape (#64251)
* pin shape

* Update docs/questions/visualizations/map.md

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

* wording

Clarified limitations on data points for maps and added details about pin types.

* image

---------

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2025-09-30 15:15:11 -04:00
John Swanson
bc28d50013 fix: make popular tables clickable (#64208)
Very silly bug, we were just populating the ID with the wrong
thing (`(:database-id table)` instead of `(:db_id table)`), so the FE
couldn't build links correctly.

There was a mismatch between `recent_views` and `popular_items`
endpoints. They have different queries, but are using the same
postprocessing function. So I needed to unify them: both now use
`:db_id`.
2025-09-30 11:31:59 -07:00
Timofey Kachalov
d0af14f90d Fix SegmentedControl colors (#64107) 2025-09-30 20:25:53 +04:00
Eric Normand
6afcf2aed5 Fix ssh tunneling for clickhouse (#63464)
* Fix ssh tunneling for clickhouse

* Remove questionmark

* Use kebab case keys

* Fix kondo;

This is also fixed in https://github.com/metabase/metabase/pull/63645,
so there may be a conflict.

* Don't squash exceptions.

* remove some tests, fix some tests

I removed some tests. Let's be very clear why this is happening. Push
back if you disagree:

```clojure
(deftest clickhouse-connection-fails-test
  (mt/test-driver :clickhouse
    (mt/with-temp [:model/Database db {:details (assoc (:details (mt/db)) :password "wrongpassword") :engine :clickhouse}]
      (testing "`driver/database-supports? :uploads` does not throw even if the connection fails."
        (is (false? (driver/database-supports? :clickhouse :uploads db)))
        (is (false? (driver/database-supports? :clickhouse :uploads nil))))
      (testing "`driver/database-supports? :connection-impersonation` does not throw even if the connection fails."
        (is (false? (driver/database-supports? :clickhouse :connection-impersonation db)))
        (is (false? (driver/database-supports? :clickhouse :connection-impersonation nil))))
      (testing (str "`sql-jdbc.conn/connection-details->spec` does not throw even if the connection fails, "
                    "and doesn't include the `select_sequential_consistency` parameter.")
        (is (nil? (:select_sequential_consistency (sql-jdbc.conn/connection-details->spec :clickhouse db))))
        (is (nil? (:select_sequential_consistency (sql-jdbc.conn/connection-details->spec :clickhouse nil))))))))
```

This test is _GONE_ because it's insane that we are testing that
`conneciton-details->spec` and `database-supports?` is resilient to
opening a connection to a server. That's nuts. We don't do that any
more. Should be the same as if checking `(+ 1 1)` doesn't open a network
connection. We get version information in

```clojure
(def ^:private ^{:arglists '([database])} get-clickhouse-version
  (memoize/ttl
   ^{:clojure.core.memoize/args-fn (fn [[database]] (:details database))}
   (fn [database]
     (sql-jdbc.execute/do-with-connection-with-options
      :clickhouse
      (sql-jdbc.conn/db->pooled-connection-spec database)
      nil
      (fn [^java.sql.Connection conn]
        (with-open [ver-stmt (.createStatement conn)
                    ver-rset (.executeQuery ver-stmt clickhouse-version-query)
                    cloud-stmt (.createStatement conn)
                    cloud-rset (.executeQuery cloud-stmt "SELECT value='1' FROM system.settings WHERE name='cloud_mode'")]
          (cond-> nil
            (.next ver-rset)
            (assoc :version          (.getString ver-rset 1)
                   :semantic-version {:major (.getInt ver-rset 2)
                                      :minor (.getInt ver-rset 3)})

            (.next cloud-rset)
            (assoc :cloud (.getBoolean cloud-rset 1)))))))
   :ttl/threshold default-cache-ttl))

(defmethod driver/dbms-version :clickhouse
  [_driver db]
  (get-clickhouse-version db))
```

We don't need to run this query every time you get a connection spec or
ask if the database supports something. We have the version and if it's
cloud and that's sufficient. The only reason it was opening a connection
was to run the cloud check (`select value=1 from system.settings where
name=cloud_mode`) above.

Also removed:
```clojure
(deftest impersonation-support-check-over-ssh-tunnel-test
  (testing "driver-supports? :connection-impersonation should work through an SSH tunnel (#62377)"
    ;; Use an in-memory SSH server that forwards to the correct port through localhost.
    ;; Since the real database is still reachable locally, it's possible for the driver to ignore the SSH tunnel and
    ;; still connect to the DB. So this test runs twice, once with all correct and once with an incorrect SSH
    ;; password. The latter check should fail (returning false for `driver-supports?`) if the SSH tunnel is being
    ;; respected.
    (mt/test-driver :clickhouse
      (let [username "username", password "password"]
        (with-open [ssh-server (driver.tu/basic-auth-ssh-server username password)]
          (doseq [[correct-password? ssh-password] [[true password] [false "wrong-password"]]]
            (memoize/memo-clear! @#'clickhouse.version/get-clickhouse-version)
            (let [ssh-port (.getPort ssh-server)]
              (with-ssh-tunnel! {:tunnel-enabled true
                                 :tunnel-host "localhost"
                                 :tunnel-auth-option "password"
                                 :tunnel-port ssh-port
                                 :tunnel-user username
                                 :tunnel-pass ssh-password}
                (testing (str "using " ssh-password)
                  (is (= correct-password?
                         (driver/database-supports? :clickhouse :connection-impersonation (mt/id)))))))))))))
```

Again, this is verifying connection resiliency in a call
to `(driver/database-supports? :clickhouse
:connection-impersonation (mt/id))` and ensuring that it uses the ssh
tunnel. This entire concern is gone, and a red flag that it ever
existed.

Changes:
a few tests used the `db` from `with-temp` to do stuff, but now that we
require them to have dbms_version i populated that

in the version check i thought i saw one in test going through with
`dbms-version` and not `dbms_version` so i'm checking both just to be
sure. it's a bit sloppy

* small changes and add back test

* handle dbms-version vs. dbms_version

* fix kondo

---------

Co-authored-by: Riley Thompson <riley@metabase.com>
Co-authored-by: dan sutton <dan@dpsutton.com>
Co-authored-by: Riley Thompson <rileythompson99@gmail.com>
2025-09-30 11:53:45 -04:00
yaojie
99d7d54af3 Set send-server-version? to false to hide Server version from response headers (#57257)
* Set send-server-version? to be false by default

* Update test
2025-09-30 16:40:19 +01:00
Dan Stone
1610538386 MariaDB transform cancellation fix (#64092) 2025-09-30 16:13:41 +01:00
Bergbok
1d94009c3d Update broken link in README (#63000) 2025-09-30 11:12:31 -04:00
Jeff Bruemmer
b8360b6da3 docs - document layout (#64209)
* document layout

* do stuff
2025-09-30 14:58:10 +00:00
Oleksandr Solovyov
cb13bdc086 simpler way to set token: mt/with-random-premium-token! (#64188) 2025-09-30 17:06:53 +03:00
Timofey Kachalov
1704f14bda refactor(sdk): Properly reinitialize metabaseConfig watcher for EmbedJS wizard (#64235) 2025-09-30 17:48:14 +04:00
John Swanson
593ecfb952 Use SQL SECURITY INVOKER for mysql views (#62537)
[UXW-263: Aurora MySQL AppDB can't create Metabase Analytics views
without using `SQL SECURITY
INVOKER`](https://linear.app/metabase/issue/UXW-263/aurora-mysql-appdb-cant-create-metabase-analytics-views-without-using)

Updates all the MySQL view creations to use `SQL SECURITY INVOKER`.
2025-09-30 06:07:10 -07:00
github-automation-metabase
611220c5e0 Update generated docs (master) (#64216)
Co-authored-by: github-actions <github-actions@github.com>
2025-09-30 08:56:57 -04:00
Dan Stone
b77f570c57 Add python runner logs as artifacts on failed tests (#64093)
* Factor out setup/log actions for python runner, use in driver tests

* Remove redundant cd

Co-authored-by: Nicolò Maria Mezzopera <nicolo.mezzopera@metabase.com>

---------

Co-authored-by: Nicolò Maria Mezzopera <nicolo.mezzopera@metabase.com>
2025-09-30 11:59:24 +01:00
Timofey Kachalov
8ba64b7bcd fix(sdk): Fix SDK refresh token race-conditions (#64094) 2025-09-30 13:09:58 +04:00
Nicola Mometto
e1dcfdab2e Do not realize objects in memory early for python transform results (#64034)
* partition documents for mongo insert

* feat: s3/read-to-stream

* don't keep files in memory, stream them

* fix tests

* proper types

* slurp conditionally

* replace_existing

* nil safe

* Tweak InputStream lifetimes

* Fix test now read-to-string returns nil

* Fix kondo / reflection

---------

Co-authored-by: Dan Stone <wotbrew@gmail.com>
2025-09-30 09:42:34 +01:00
Timofey Kachalov
5c9b282361 Update typedoc and its plugins versions (#64219) 2025-09-30 11:43:47 +04:00
Oleksandr Solovyov
cf5c7bd4c3 [search] better search for CamelCased tables and dbs (#64043) 2025-09-30 06:20:14 +00:00
Nemanja Glumac
46adff882f Fix metrics-collection e2e flake (#64204)
We're making the assertion a bit more flexible
without losing the confidence that this test
was supposed to give us when it was created.
2025-09-30 07:50:30 +02:00
Cam Saul
87923e1e1d Fix duplicate coercion on custom columns from joins (#63941)
* Fix duplicate coercion on custom columns from joins

* Fix #63991

* Test fixes 🔧

* Another round of test fixes 🔧

* Test fixes 🔧

* Kondo lint fixes 🔧
2025-09-29 22:16:56 -07:00
Romeo Van Snick
5bd544baf5 Fix native transform editor results UI warts (#64081)
* Pass className to native editor

* Add missing border on top of native editor

* Set minimal editor height so the results panel is always visible

* Move initial height calculation to hook
2025-09-30 14:04:22 +10:00
Anderson
f8d410f858 Change dark ClickHouse icon to be yellow (#64213) 2025-09-29 22:57:18 -03:00
Aleksandr Lesnenko
cfa62e4b8b original card description should be provided to visualizer (#64197) 2025-09-30 00:28:07 +00:00
Romeo Van Snick
fa2820320e Fix zoom and hover on map while editing (#64174)
* Add missing import

* Use late-binding for handlers so they can change without having to recreate markers

* Allow isEditing props to re-render Map component

* Allow disabling zoom control on maps

* Allow clicking the Map action buttons

* Disable zoom and hover when in editing mode

* Add repro for metabase#64138

* Fix e2e tests
2025-09-30 09:03:51 +10:00
Romeo Van Snick
33070f6e06 Fix completion results (#64080)
* Do not shrink grid rows in completion results

* Add e2e test for metabase#63711
2025-09-30 09:03:13 +10:00
Denis Berezin
f43c6462d8 Add support for special intl chars for email formatting (#64060)
* Add support for special intl chars for email formatting

* Fix review comments

* Fix unit tests
2025-09-30 00:16:28 +04:00
Brad Anderson
8f4ccdf2ba Fix #63904: Corrects dashcard selector usage (#64140)
* UXW-1766 Fixes issue where getDashboardHeaderParameters looks at all cached dashcards rather than just the ones from the currently viewed dashboard

* UXW-1766 Adds/updates unit tests
2025-09-29 13:20:34 -04:00
Nemanja Glumac
788bf01ea9 Report Metabot feedback directly to Harbormaster (#64040)
We're introducing a new endpoint in metabase/metabase. It sends the feedback to the backend which then (1) fetches the premium token and (2) constructs the correct store api URL with the same feedback as the payload. Our backend acts as a proxy because the api url contains the token and we can't send it from the FE.
2025-09-29 19:06:51 +02:00
Noah Moss
c4de2725e3 Extract native query content for search indexing rather than the entire dataset_query structure (#64125)
* Extract native query content for search indexing rather than the entire dataset_query structure

* remove inline def

* Fix doc string

* Update search spec field-fields logic to support the :fn syntax to fix test

* Remvoe query_type from expected card spec fields as we're no longer using it to detect native query changes

* Address feedback on :fn semantics, and add a more detailed docstring to define-spec macro
2025-09-29 12:17:17 -04:00
Braden Shepherdson
cd0965a299 [Vertica] Support Vertica UUID columns as :type/UUID (#62512) 2025-09-29 15:38:59 +00:00
Uladzimir Havenchyk
95452f41d3 feat: use new detail view in metadata preview (#63968)
* feat: use new detail view in metadata preview

* drop relationships

* fix e2e helper

* use correct '

* make table responsive

* update helper

* adjust values in tests

* use useSampleData

* rework approach

* update helper

* oops, lost changes

* restore test

* let's add table just in case

* another attempt

* responsive

* restore indexes

* rename index -> rowNumber

* show details group in more cases

* remove force: true

* fix index
2025-09-29 17:31:30 +03:00
John Swanson
b323ecd565 Add a timeout for slow LDAP (#63852)
When the LDAP server is slow, we should fall back to normal
authentication.

For now, use a configurable timeout, defaulting to 15 seconds - this
seems on the high side of reasonable.

[UXW-291: When LDAP is configured, then normal email logins can be blocked if LDAP server is slow/down](https://linear.app/metabase/issue/UXW-291/when-ldap-is-configured-then-normal-email-logins-can-be-blocked-if)
2025-09-29 07:08:10 -07:00
Edward Paget
b2939990fc fix: correct filtering for internal collections (#64065)
* fix: correct filtering for internal collections

* fixup! set is_sample flag on archived collection

filter out analytics namespace
2025-09-29 08:25:36 -05:00
Alexander Polyankin
c63dff0d7b Fixes viz settins for details-only columns (#64113) 2025-09-29 09:12:38 -04:00
John Swanson
521eed05c5 Do not URL-encode default redirect URL (#63965)
I think this was just a mistake - there's no need to URL-encode this and
it actually breaks the redirect.
2025-09-29 06:05:13 -07:00
John Swanson
e88afceba9 Index analytics collections (#64064)
These were excluded from the index, but they should really be in there.
2025-09-29 06:04:02 -07:00
John Swanson
ac2003368e When deleting DB, hide cards from search (#64112)
* When deleting DB, hide cards from search

We have a current issue with search: deleting items does not actually
remove them from search.

This is a particularly nasty version of this, because while usually a
delete follows an update (that marks the card as archived,
which *does* remove it from search), in this case we're just deleting
the items directly as part of an `ON DELETE CASCADE`.

This is a bit hacky, but for now we can just mark them as `archived`
before we delete them to hide them from search.

[UXW-602: Search API returns inexisting cards](https://linear.app/metabase/issue/UXW-602/search-api-returns-inexisting-cards)
2025-09-29 06:03:53 -07:00
Timofey Kachalov
1d2fc06b60 feat(embed-js): Add support of fetchRequestToken for embed-js (#64004) 2025-09-29 16:12:43 +04:00
Kamil Mielnik
581c1e7a38 WRK-871 Details view: JSON formatting missing in details view (#63892)
* Format JSON in new detail view

* Use mantine props

* Use Code

* Use CodeEditor
2025-09-29 10:31:59 +00:00
Romeo Van Snick
17a61a81c8 Add missing import (#64171) 2025-09-29 11:17:26 +02:00
Romeo Van Snick
1d699a5fae Use the entities display name if possible (#64076) 2025-09-29 13:32:11 +10:00
Ngoc Khuat
277d1fdaff Skip sync transforms temp table (#64037) 2025-09-28 15:00:43 +00:00
Oleksandr Yakushev
82b3ba1fcc [perf] Optimize metabase.lib.field.resolution/merge-metadata (#62425) 2025-09-28 13:37:56 +03:00
Dan Stone
dd7845cc83 Improve python error feedback capability (#64053)
* Add i18n / formatting to existing log calls

* Cleanup log conveyance to failure :message

* Improve error feedback capability

- Ensure exception messages do not leak to user logs by default 'Something went wrong'
- Exceptions can carry a `transform-message` key to provide a custom failure message (with i18n)
- Defined messaging for:
   - Error during table copy
   - Error during execution
   - Error during writeback

I would like to follow up with additional context in the form of message log entries for further progress / context of any failure.

Logs can be correlated to metabase logs for stack traces or further debugging. Python output remains presented as-is (traceback etc).
2025-09-27 15:31:57 +00:00
Edward Paget
7e7ed1ac9f fix(scim): support patch requests without a path (#64130)
adheres to the standard and accomodates how we can get these requests
from Okta.
2025-09-26 16:18:45 -05:00
Jeff Bruemmer
19387d8d29 add transforms option (#64128) 2025-09-26 16:45:46 -04:00
Alex Yarosh
7ac8feb49b docs: embedding hub changes (#64012)
* embedding hub changes

* more edits

* Apply suggestions from code review

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

---------

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2025-09-26 20:04:08 +00:00
Alex Yarosh
4447e2c6bc expand embedding comparison table (#64071) 2025-09-26 15:47:01 -04:00
Oleksandr Yakushev
8434b23764 [match] Prevent match-lite from extracting common result that depends on uncommon bindings (#64073)
* [match] Prevent match-lite from extracting common result that depends on uncommon bindings

* [match] Fail if match-lite's :guard is provided with incorrect predicate syntax

Also fix its treatment of named functions passed as predicates.
2025-09-26 22:39:26 +03:00
Jeff Bruemmer
be72407fc8 docs - RLS notes (#64111) 2025-09-26 19:34:07 +00:00
John Swanson
e530911305 Correctly render empty series in static viz (#63987)
* Correctly render empty series in static viz

[UXW-1754: Row chart with an empty series fails to send on static viz](https://linear.app/metabase/issue/UXW-1754/row-chart-with-an-empty-series-fails-to-send-on-static-viz)

`getColumnValueFormatter` and `getColumnValueStaticFormatter` had
different behaviors for empty strings, so this worked on the frontend
but failed for static visualizations.

* cleanup

---------

Co-authored-by: Aleksandr Lesnenko <alxnddr@gmail.com>
2025-09-26 11:49:59 -07:00
Noah Moss
ca9ba3390d make u/profile log lines look nicer (#64114) 2025-09-26 18:01:53 +00:00
Oleksandr Solovyov
a947f3cb94 [advanced-config] uppercased letter in users email should not fail (#64109)
fixes #62894
2025-09-26 17:20:39 +00:00
Dan Stone
33de954fcf Temporarily disable python transforms tests for BQ/Snowflake (#64118) 2025-09-26 17:15:34 +00:00
Kamil Mielnik
db79c64d3d DSN-73 Verified badge icon should be correct size and nearer text in model and saved question titles (#63871)
* Fix verified badge styling

* Misalign icons :see-no-evil:

* Remove min width on dashboard caption input

* Fix overflow
2025-09-26 16:14:41 +00:00
Jakub Chodorowicz
3e646a69f3 Move dev instances upsell down on embedding settings pages (#64099) 2025-09-26 17:57:27 +02:00
Sébastien
d6c15aab01 Closes EMB-853 renamed legends get ignored by translation (#64023)
* Closes EMB-853 renmaed legends get ignored by translation

* feedback

* if -> ts-pattern
2025-09-26 16:04:57 +02:00
Jeff Bruemmer
34c224b5f2 docs - verified content (#64067) 2025-09-26 13:28:33 +00:00
Sébastien
568443a99c Closes EMB-407 transparent background download pdf (#63836)
* Closes EMB-407 transparent background download pdf

* e2e

* cleanup
2025-09-26 15:18:24 +02:00
github-automation-metabase
5fe1efe15b Update generated docs (master) (#64078)
Co-authored-by: github-actions <github-actions@github.com>
2025-09-26 06:46:56 -04:00
Alexander Polyankin
894176cf26 Add missing transform filters (#63998)
* Params

* Params

* Params

* Params

* Params

* Params

* Rename param

* Transform params

* Transform params

* Transform params

* Transform params

* Transform params

* Transform params

* Transform params

* Transform params

* fixes

* add a test

* add a test

* add a test

* add a test

* add a test

* Fix field filters for type/TextLike

* Fix polling
2025-09-26 06:39:11 -04:00
529 changed files with 17916 additions and 4434 deletions

View File

@@ -624,10 +624,11 @@
metabase.test/with-non-admin-groups-no-root-collection-perms hooks.common/do*
metabase.test/with-persistence-enabled! hooks.metabase.model-persistence.test-util/with-persistence-enabled!
metabase.test/with-prometheus-system! hooks.common/with-two-bindings
metabase.test/with-random-premium-token! hooks.common/with-one-binding
metabase.test/with-temp hooks.toucan2.tools.with-temp/with-temp
metabase.test/with-temp-file hooks.metabase.test.util/with-temp-file
metabase.test/with-temporary-setting-values hooks.metabase.test.util/with-temporary-setting-values
metabase.users.api-test/with-temp-user-email! hooks.common/with-one-binding
metabase.users.api-test/with-temp-user-email! hooks.common/with-one-binding
metabase.util.i18n/deferred-trs hooks.metabase.util.i18n/deferred-trs
metabase.util.i18n/deferred-tru hooks.metabase.util.i18n/deferred-tru
metabase.util.i18n/trs hooks.metabase.util.i18n/trs

View File

@@ -428,6 +428,11 @@
:api #{metabase.database-routing.core}
:uses #{premium-features}}
dependencies
{:team "Querying"
:api #{metabase.premium-features.core}
:uses #{premium-features}}
driver
{:team "Drivers"
:api #{metabase.driver
@@ -556,6 +561,11 @@
models
util}}
graph
{:team "Querying"
:api #{metabase.graph.core}
:uses #{util}}
indexed-entities
{:team "UX West"
:api #{metabase.indexed-entities.api
@@ -725,6 +735,7 @@
metabase.native-query-snippets.core}
:uses #{api
collections
events
lib
models
permissions
@@ -1241,6 +1252,7 @@
events
lib
models
premium-features
query-processor
settings
task
@@ -1349,9 +1361,11 @@
users
{:team "UX West"
:api #{metabase.users.api
metabase.users.core
metabase.users.init
metabase.users.models.user
metabase.users.models.user-parameter-value}
metabase.users.models.user-parameter-value
metabase.users.schema}
:uses #{analytics
api
appearance
@@ -1715,6 +1729,29 @@
warehouse-schema
warehouses}}
enterprise/dependencies
{:team "Querying"
:api #{metabase-enterprise.dependencies.api
metabase-enterprise.dependencies.core
metabase-enterprise.dependencies.init}
:uses #{analyze
api
app-db
collections
config
driver
events
graph
lib
lib-be
models
native-query-snippets
premium-features
queries
query-processor
task
util}}
enterprise/documents
{:team "UX West"
:api #{metabase-enterprise.documents.api
@@ -1833,6 +1870,7 @@
search
server
settings
store-api
sync
system
task

View File

@@ -232,7 +232,6 @@
metabase.test.data/$ids [[:inner 0]]
metabase.test.data/dataset [[:inner 0]]
metabase.test.data/mbql-query [[:inner 0]]
metabase.test.data/native-query [[:block 0]]
metabase.test.data/query [[:block 1]]
metabase.test.data/run-mbql-query [[:inner 0]]
metabase.test.data/with-empty-h2-app-db! [[:block 0]]
@@ -253,7 +252,6 @@
metabase.test/format-rows-by [[:default]]
metabase.test/let-url [[:block 1]]
metabase.test/mbql-query [[:inner 0]]
metabase.test/native-query [[:block 0]]
metabase.test/query [[:block 1]]
metabase.test/run-mbql-query [[:inner 0]]
metabase.test/test-driver [[:inner 0]]

View File

@@ -0,0 +1,36 @@
name: Cleanup Python Runner
description: Cleanup python-runner services and upload logs as artifacts
inputs:
upload-logs:
description: 'Whether to upload logs (typically on failure)'
required: false
default: 'false'
logs-artifact-suffix:
description: 'The unique name of the .zip artifact containing the logs'
required: false
default: ''
runs:
using: composite
steps:
- name: Cleanup python-runner services
if: always()
run: |
echo "Stopping python-runner services..."
if [ -d "python-runner" ]; then
cd python-runner
make stop || echo "WARNING: Failed to stop python runner"
fi
shell: bash
- name: Upload python-runner logs
if: ${{inputs.upload-logs == 'true' && inputs.logs-artifact-suffix != ''}}
uses: actions/upload-artifact@v4
with:
name: python-runner-logs-${{inputs.logs-artifact-suffix}}
path: |
python-runner.out.log
python-runner.err.log
retention-days: 1
if-no-files-found: warn

View File

@@ -0,0 +1,27 @@
name: Setup Python Runner
description: Setup python-runner for tests
inputs:
metabase-automation-token:
description: Token for accessing the python-runner repository
required: true
python-runner-version:
description: Git tag or commit of the python-runner repository to checkout
required: false
default: 'v0.0.1'
runs:
using: composite
steps:
- name: Clone and setup python-runner
run: |
echo "Cloning python-runner repository..."
git clone --depth 1 --branch ${{ inputs.python-runner-version }} \
https://${{ inputs.metabase-automation-token }}@github.com/metabase/python-runner-container.git python-runner
echo "Starting python-runner..."
cd python-runner
make run-ci 1> ../python-runner.out.log 2> ../python-runner.err.log
make logs 1>> ../python-runner.out.log 2>> ../python-runner.err.log &
cd ..
shell: bash
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ inputs.metabase-automation-token }}

View File

@@ -24,6 +24,7 @@ git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabas
if [ "$SKIP_START" = false ]; then
echo "Starting python-runner..."
cd python-runner
make run-ci
make run-ci 1> ../python-runner.out.log 2> ../python-runner.err.log
make logs 1>> ../python-runner.out.log 2>> ../python-runner.err.log &
cd ..
fi

View File

@@ -6,6 +6,10 @@ on:
skip:
type: boolean
default: false
python-runner-version:
type: string
default: 'v0.0.1'
description: 'Git tag or commit of python-runner repository to use'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-drivers
@@ -115,10 +119,11 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
- name: Transforms Python Tests
test-args: >-
:only-tags [:mb/transforms-python-test]
needs-python-runner: true
# DS 2025-09-26 temporarily disabled while we diagnose flakes
# - name: Transforms Python Tests
# test-args: >-
# :only-tags [:mb/transforms-python-test]
# needs-python-runner: true
env:
CI: 'true'
DRIVERS: bigquery-cloud-sdk
@@ -134,10 +139,12 @@ jobs:
- uses: actions/checkout@v4
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: ./.github/scripts/setup-python-runner.sh
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
python-runner-version: ${{ inputs.python-runner-version }}
- name: Test BigQuery Cloud SDK driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-bigquery-cloud-sdk-ee'
@@ -153,6 +160,12 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
be-tests-clickhouse-ee:
if: ${{ !inputs.skip }}
@@ -182,9 +195,10 @@ jobs:
- uses: actions/checkout@v4
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: ./.github/scripts/setup-python-runner.sh
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
python-runner-version: ${{ inputs.python-runner-version }}
- name: Add ClickHouse TLS instance to /etc/hosts
run: |
sudo echo "127.0.0.1 server.clickhouseconnect.test" | sudo tee -a /etc/hosts
@@ -201,6 +215,7 @@ jobs:
clickhouse_cluster_node2
nginx
- name: Test Clickhouse driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-clickhouse-ee'
@@ -216,6 +231,12 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
be-tests-druid-ee:
needs: determine-driver-skips
@@ -376,10 +397,12 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup python-runner
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: ./.github/scripts/setup-python-runner.sh
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
python-runner-version: ${{ inputs.python-runner-version }}
- name: Test H2 driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-h2'
@@ -397,6 +420,12 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
be-tests-mysql-mariadb:
if: ${{ !inputs.skip }}
@@ -430,7 +459,7 @@ jobs:
- name: Driver Tests
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
exclude-tag: ':mb/transforms-python-test'
- name: Transforms Python Tests
test-args: >-
:only-tags [:mb/transforms-python-test]
@@ -490,11 +519,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup python-runner
#if: ${{ matrix.job.needs-python-runner == true }}
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: ./.github/scripts/setup-python-runner.sh
if: ${{ matrix.job.needs-python-runner == true }}
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
python-runner-version: ${{ inputs.python-runner-version }}
- name: Test ${{ matrix.version.name }}
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: ${{ matrix.version.junit-name }}
@@ -512,6 +543,12 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
be-tests-mongo:
needs: determine-driver-skips
@@ -555,10 +592,12 @@ jobs:
- uses: actions/checkout@v4
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: ./.github/scripts/setup-python-runner.sh
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
python-runner-version: ${{ inputs.python-runner-version }}
- name: Test ${{ matrix.version.name }}
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: ${{ matrix.version.junit-name }}
@@ -574,6 +613,12 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
be-tests-mongo-ssl:
needs: determine-driver-skips
@@ -769,7 +814,7 @@ jobs:
- name: Driver Tests
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
exclude-tag: ':mb/transforms-python-test'
- name: Transforms Python Tests
test-args: >-
:only-tags [:mb/transforms-python-test]
@@ -816,11 +861,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup python-runner
#if: ${{ matrix.job.needs-python-runner == true }}
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: ./.github/scripts/setup-python-runner.sh
if: ${{ matrix.job.needs-python-runner == true }}
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
python-runner-version: ${{ inputs.python-runner-version }}
- name: Test ${{ matrix.version.name }} (${{ matrix.job.name }})
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-${{ matrix.version.junit-name }}'
@@ -838,6 +885,12 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
be-tests-presto-jdbc-ee:
needs: determine-driver-skips
@@ -948,10 +1001,12 @@ jobs:
- uses: actions/checkout@v4
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: ./.github/scripts/setup-python-runner.sh
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
python-runner-version: ${{ inputs.python-runner-version }}
- name: Test ${{ matrix.job.name }}
id: test-driver
if: ${{ !matrix.job.skip }}
uses: ./.github/actions/test-driver
with:
@@ -969,6 +1024,12 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
be-tests-snowflake-ee:
if: ${{ !inputs.skip }}
@@ -982,10 +1043,11 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
- name: Transforms Python Tests
test-args: >-
:only-tags [:mb/transforms-python-test]
needs-python-runner: true
# DS 2025-09-26 temporarily disabled while we diagnose flakes
# - name: Transforms Python Tests
# test-args: >-
# :only-tags [:mb/transforms-python-test]
# needs-python-runner: true
env:
CI: 'true'
DRIVERS: snowflake
@@ -1009,10 +1071,12 @@ jobs:
- uses: actions/checkout@v4
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: ./.github/scripts/setup-python-runner.sh
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
python-runner-version: ${{ inputs.python-runner-version }}
- name: Test Snowflake driver
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-snowflake-ee'
@@ -1028,6 +1092,12 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
be-tests-sparksql-ee:
needs: determine-driver-skips
@@ -1138,10 +1208,12 @@ jobs:
- uses: actions/checkout@v4
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: ./.github/scripts/setup-python-runner.sh
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
python-runner-version: ${{ inputs.python-runner-version }}
- name: Test ${{ matrix.version.name }}
id: test-driver
uses: ./.github/actions/test-driver
with:
junit-name: ${{ matrix.version.junit-name }}
@@ -1157,6 +1229,12 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
# TODO(dpsutton, 2025-08-20): Re-enable starburst when we figure out why it randomly dies in CI
# be-tests-starburst-ee:

View File

@@ -28,6 +28,11 @@ on:
split_index: # index of the chunk
required: false
type: number
python-runner-version:
type: string
default: 'v0.0.1'
description: 'Git tag or commit of python-runner repository to use'
required: false
jobs:
e2e-tests:
@@ -65,9 +70,10 @@ jobs:
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
- name: Setup python-runner
env:
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: ./.github/scripts/setup-python-runner.sh
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
python-runner-version: ${{ inputs.python-runner-version }}
- name: Prepare Docker containers
uses: ./.github/actions/e2e-prepare-containers
@@ -246,3 +252,9 @@ jobs:
const summary = formatSummary(results);
await core.summary.addRaw(summary).write();
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: false

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -29,7 +29,8 @@
- Use the linter as a way to know that you are adhering to conventions in place in the codebase
- **Lint Changes:** `./bin/mage kondo-updated HEAD`
- **Format:** `./bin/mage cljfmt-files [path]`
- **Test file:** `clojure -X:dev:test :only namespace/test-name`
- **Run a test:** `./bin-mage run-tests namespace/test-name`
- **Run all tests in a namespace:** `./bin-mage run-tests namespace`
- **Check Code Readability** `./bin/mage -check-readable` with optional line-number
- Run this after every change to Clojure code, only accept readable code
- **Evaluating Clojure Code** `./bin/mage -repl '<code>'`

View File

@@ -2,7 +2,7 @@
[Metabase](https://www.metabase.com) is the easy, open-source way for everyone in your company to ask questions and learn from data.
![Metabase Product Screenshot](docs/images/metabase-product-screenshot.png)
![Metabase Product Screenshot](https://www.metabase.com/images/metabase-product-screenshot-updated.png)
[![Latest Release](https://img.shields.io/github/release/metabase/metabase.svg?label=latest%20release)](https://github.com/metabase/metabase/releases)
[![codecov](https://codecov.io/gh/metabase/metabase/branch/master/graph/badge.svg)](https://codecov.io/gh/metabase/metabase)
@@ -12,28 +12,29 @@
The easiest way to get started with Metabase is to sign up for a free trial of [Metabase Cloud](https://store.metabase.com/checkout). You get support, backups, upgrades, an SMTP server, SSL certificate, SoC2 Type 2 security auditing, and more (plus your money goes toward improving Metabase). Check out our quick overview of [cloud vs self-hosting](https://www.metabase.com/docs/latest/cloud/cloud-vs-self-hosting). If you need to, you can always switch to [self-hosting](https://www.metabase.com/docs/latest/installation-and-operation/installing-metabase) Metabase at any time (or vice versa).
## Features
## Key Features
- [Set up in five minutes](https://www.metabase.com/docs/latest/setting-up-metabase.html) (we're not kidding).
- [Set up in five minutes](https://www.metabase.com/docs/latest/configuring-metabase/setting-up-metabase) (we're not kidding).
- Let anyone on your team [ask questions](https://www.metabase.com/docs/latest/questions/introduction) without knowing SQL.
- Use the [SQL editor](https://www.metabase.com/docs/latest/questions/native-editor/writing-sql) for more complex queries.
- Build handsome, interactive [dashboards](https://www.metabase.com/docs/latest/users-guide/07-dashboards.html) with filters, auto-refresh, fullscreen, and custom click behavior.
- Build handsome, interactive [dashboards](https://www.metabase.com/docs/latest/dashboards/introduction) with filters, auto-refresh, fullscreen, and custom click behavior.
- Create [models](https://www.metabase.com/learn/metabase-basics/getting-started/models) that clean up, annotate, and/or combine raw tables.
- Define canonical [segments and metrics](https://www.metabase.com/docs/latest/administration-guide/07-segments-and-metrics.html) for your team to use.
- Send data to Slack or email on a schedule with [dashboard subscriptions](https://www.metabase.com/docs/latest/users-guide/dashboard-subscriptions).
- Set up [alerts](https://www.metabase.com/docs/latest/users-guide/15-alerts.html) to have Metabase notify you when your data changes.
- [Embed charts and dashboards](https://www.metabase.com/docs/latest/administration-guide/13-embedding.html) in your app, or even [your entire Metabase](https://www.metabase.com/docs/latest/enterprise-guide/full-app-embedding.html).
- Define canonical [segments and metrics](https://www.metabase.com/docs/latest/data-modeling/metrics) for your team to use.
- Send data to Slack or email on a schedule with [dashboard subscriptions](https://www.metabase.com/docs/latest/dashboards/subscriptions).
- Set up [alerts](https://www.metabase.com/docs/latest/questions/alerts) to have Metabase notify you when your data changes.
- [Embed charts and dashboards](https://www.metabase.com/docs/latest/embedding/introduction) in your app, or even [your entire Metabase](https://www.metabase.com/docs/latest/embedding/interactive-embedding).
- Build with the [Embedded Analytics SDK for React](https://www.metabase.com/docs/latest/embedding/sdk/introduction) to embed standalone components with custom styling that matches your app's design.
Take a [tour of Metabase](https://www.metabase.com/learn/getting-started/tour-of-metabase).
Take a [tour of Metabase](https://www.metabase.com/learn/metabase-basics/overview/tour-of-metabase).
## Supported databases
- [Officially supported databases](./docs/databases/connecting.md#connecting-to-supported-databases)
- [Community drivers](./docs/developers-guide/partner-and-community-drivers.md)
- [Community drivers](./docs/developers-guide/community-drivers.md)
## Installation
Metabase can be run just about anywhere. Check out our [Installation Guides](https://www.metabase.com/docs/latest/operations-guide/installing-metabase).
Metabase can be run just about anywhere. Check out our [Installation Guides](https://www.metabase.com/docs/latest/installation-and-operation/installing-metabase).
## Contributing
@@ -43,7 +44,7 @@ In order to spin up a development environment, you need to start the front end a
### Frontend quick setup
The following command will install the Javascript dependencies:
The following command will install the JavaScript dependencies:
```bash
yarn install
@@ -63,7 +64,7 @@ yarn build-hot
### Backend quick setup
In order to run the backend, you'll need to build the drivers first, and then start the backend:
To run the backend, you'll need to build the drivers first, and then start the backend:
```bash
./bin/build-drivers.sh
@@ -78,13 +79,13 @@ We want Metabase to be available in as many languages as possible. See which tra
## Extending Metabase
Hit our Query API from Javascript to integrate analytics. Metabase enables your application to:
Hit our Query API from JavaScript to integrate analytics. Metabase enables your application to:
- Build moderation interfaces.
- Export subsets of your users to third party marketing automation software.
- Provide a custom customer lookup application for the people in your company.
Check out our guide, [Working with the Metabase API](https://www.metabase.com/learn/administration/metabase-api).
Check out our guide, [Working with the Metabase API](https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/metabase-api).
## Security Disclosure
@@ -98,6 +99,14 @@ See [LICENSE.txt](./LICENSE.txt) for details.
Unless otherwise noted, all files © 2025 Metabase, Inc.
## [Metabase Experts](https://www.metabase.com/partners/)
## Community
- [Discourse](https://discourse.metabase.com/)
- [Twitter](https://x.com/metabase)
- [LinkedIn](https://www.linkedin.com/company/metabase/)
- [YouTube](https://www.youtube.com/@metabasedata)
- [Reddit](https://www.reddit.com/r/Metabase/)
## Metabase Experts
If you’d like more technical resources to set up your data stack with Metabase, connect with a [Metabase Expert](https://www.metabase.com/partners/?utm_source=readme&utm_medium=metabase-expetrs&utm_campaign=readme).

23
bb.edn
View File

@@ -1,4 +1,4 @@
{:min-bb-version "1.12.197"
{:min-bb-version "1.12.208"
;; we put path as bin, and everything is in the ./mage subdirectory,
;; so the namespaces are mage.cli, mage.format, etc.
:paths ["mage" "test" "bin/lint-migrations-file/src"]
@@ -161,7 +161,7 @@
:task (stats-repl/connect (cli/parse! (current-task)))}
notify
{:doc "Send notification for a comment (that already exists"
{:doc "Send notification for a comment (that already exists)"
:requires [[mage.be-dev :refer [nrepl-eval]]]
:options [["-c" "--comment-id ID" "Notify about this comment id"]
["-p" "--port PORT" "Port to use for the task, defaults to value in .nrepl-port"]]
@@ -172,6 +172,25 @@
(nrepl-eval "metabase-enterprise.comments.core" code (:port opts))
(nrepl-eval "metabase-enterprise.comments.core" code)))}
run-tests
{:doc "Run a tests (or a single test) against nrepl server"
:requires [[clojure.string :as str]
[mage.be-dev :refer [nrepl-eval]]]
:arg-schema [:tuple [:string {:name "NS/TEST" :desc "or just NS"}]]
:options [["-p" "--port PORT" "Port to use for the task, defaults to value in .nrepl-port"]]
:task (let [{:keys [options]
[test] :arguments} (cli/parse! (current-task))
test (if (or (str/starts-with? test "test/")
(str/starts-with? test "enterprise/"))
;; paths should come in as strings
(format "\"%s\"" test)
;; ns or var should be a symbol
(str "'" test))
code (format
"(do ((requiring-resolve 'dev.reload/reload!)) (find-and-run-tests-repl {:only %s}))"
test)]
(nrepl-eval "mb.hawk.core" (str code) (:port options)))}
ls {:doc "List all mage public and private tasks"
:examples [["./bin/mage ls" "List all tasks"]]
:requires [[mage.util :as u]]

View File

@@ -90,7 +90,7 @@
:git/sha "4998a1fdd9d3713d7034d04509e9212204773bf2"
:git/url "https://github.com/eerohele/pp"}
;; TODO: replace back to normal artifact
io.github.metabase/macaw {:mvn/version "0.2.27"} ; Parse native SQL queries
io.github.metabase/macaw {:mvn/version "0.2.28"} ; Parse native SQL queries
io.github.resilience4j/resilience4j-retry ^:antq/exclude ; 2.x do not support Java 8
{:mvn/version "1.7.1"} ; Support for retrying operations
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector

151
dev/src/dev/reload.clj Normal file
View File

@@ -0,0 +1,151 @@
(ns dev.reload
(:require
[clojure.java.shell :as shell]
[clojure.string :as str]
[metabase.util :as u])
(:import
(java.io File)
(java.nio.file Files LinkOption Paths)))
(set! *warn-on-reflection* true)
#_:clj-kondo/ignore
(defonce *reload-timestamps (atom {}))
(defn system-classpath
"Returns a sequence of File paths from the 'java.class.path' system
property."
[]
(into [] (comp (filter #(not (str/ends-with? % ".jar")))
(map #(File. ^String %))
(filter #(.isDirectory ^File %))
(map #(.getCanonicalPath ^File %)))
(.split (System/getProperty "java.class.path")
(System/getProperty "path.separator"))))
(defn find-classpath-root
"Finds the classpath directory that contains the given file path.
Returns the portion of the path to strip, or nil if not found."
[classpath file-path]
(let [canonical-file (try
(.getCanonicalPath (File. ^String file-path))
(catch Exception _ file-path))]
(->> classpath
(filter #(str/starts-with? canonical-file %))
(sort-by count >) ; Longest match first
first)))
(defn git-changed-files
"Returns a set of changed/added/untracked files from git status."
[]
(let [{:keys [exit out]} (shell/sh "git" "status" "--porcelain")]
(if (and (zero? exit)
(not (str/blank? out)))
(->> (str/split-lines out)
(map #(str/trim (subs % 3))) ; Remove status prefix (e.g., "M ", "A ", "??")
(filter #(str/ends-with? % ".clj"))
set)
#{})))
(defn file-last-modified
"Returns the last modified time of a file in milliseconds."
[file-path]
(try
(-> (Paths/get file-path (into-array String []))
(Files/getLastModifiedTime (into-array LinkOption []))
(.toMillis))
(catch Exception _ nil)))
(defn relpath->ns
"foo/bar_baz.clj -> foo.bar-baz"
[relpath]
(-> relpath
(str/replace #"\.clj$" "")
(str/replace #"/" ".")
(str/replace #"_" "-")
symbol))
(defn file-path->namespace
"Converts a file path to a namespace symbol using classpath information.
Example: enterprise/backend/src/foo/bar/baz.clj -> foo.bar.baz"
[file-path classpath]
(when (and file-path (re-find #".cljc?$" file-path))
(when-let [root (find-classpath-root classpath file-path)]
(let [canonical-file (try
(.getCanonicalPath (File. ^String file-path))
(catch Exception _ file-path))
relative-path (subs canonical-file (inc (count root)))]
(relpath->ns relative-path)))))
(defn loaded-namespaces
"Returns a set of all currently loaded namespace symbols."
[]
(set (map ns-name (all-ns))))
(defn needs-reload?
"Checks if a file needs reloading based on its last modified time."
[file-path ns-sym]
(let [last-reload (get @*reload-timestamps ns-sym)
last-modified (file-last-modified file-path)]
(or (nil? last-reload)
(nil? last-modified)
(> last-modified last-reload))))
(defn changed-namespaces []
(let [changed-files (git-changed-files)
loaded-ns (loaded-namespaces)
classpath-dirs (system-classpath)
candidates (->> changed-files
(map (fn [f] {:file f :ns (file-path->namespace f classpath-dirs)}))
(filter :ns)
(filter #(loaded-ns (:ns %))))
to-reload (filter #(needs-reload? (:file %) (:ns %)) candidates)
skipped (remove #(needs-reload? (:file %) (:ns %)) candidates)]
{:to-reload to-reload
:skipped skipped}))
(defn reload-namespaces!
[to-reload]
(->> (for [{:keys [_file ns]} to-reload]
(try
(require ns :reload)
(swap! *reload-timestamps assoc ns (System/currentTimeMillis))
[:reloaded ns]
(catch Exception e
[:failed {:ns ns :error (.getMessage e)}])))
(u/group-by first second)))
(defn reload!
"Reloads all changed files from git that are already loaded as namespaces
and have been modified since last reload.
Returns a map with :reloaded, :skipped, and :failed keys."
[]
(let [{:keys [to-reload
skipped]} (changed-namespaces)
_ (println (format "Reloading %s namespaces (%s skipped):" (count to-reload) (count skipped)))
{:keys [reloaded
failed]
:as res} (reload-namespaces! to-reload)]
(doseq [ns reloaded]
(println " ✓" ns))
(doseq [{:keys [ns error]} failed]
(println " ✗" ns ":" error))
(assoc res :skipped skipped)))
(defn clear-reload-history!
"Clears the reload timestamp history, forcing all files to reload next time."
[]
(reset! *reload-timestamps {})
(println "Reload history cleared."))
(defn show-classpath
"Shows all classpath directories for debugging."
[]
(println "\n=== Classpath Directories ===")
(run! #(println (.getCanonicalPath ^File %)) (sort (system-classpath))))
(comment
(show-classpath) ; to see what's in your classpath
(clear-reload-history!) ; if you want to force reload everything
(changed-namespaces)
(reload!))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -53,7 +53,7 @@ Some tips:
Hover over Metabot's response to:
- Copy the response.
- Give thumbs-up/thumbs-down feedback on responses. If you downvote a response, you can optionally contribute a bug report to help us improve Metabot.
- Give thumbs-up/thumbs-down [feedback on responses](#giving-feedback-on-metabot-responses). If you upvote a response, you can optionally add some feedback. If you downvote a response, you can optionally contribute a bug report to help us improve Metabot.
- Re-run the prompt with Metabot. This is useful if you've updated the chart or just want to have Metabot take another pass (since AI responses aren't deterministic, Metabot may give a different response on another run).
## Analyze charts with Metabot
@@ -68,7 +68,7 @@ You can also ask Metabot to tell you about specific tables in your database.
## How Metabot uses the query builder
When you ask Metabot to create a chart from natural language, it first looks for existing questions that might answer your request. If it finds a relevant question, it'll point you to that instead of creating something new. Otherwise, Metabot will use the [query builder](../questions/query-builder/editor.md) to create a new chart for you.
When you ask Metabot to create a chart from natural language, it first looks for existing questions that might answer your request. If it finds a relevant question, it'll point you to that instead of creating something new. Otherwise, Metabot will use the [query builder](../questions/query-builder/editor.md) to create a new chart for you.
Keep in mind that Metabot is still learning the query builder. Metabot can only handle basic query builder operations, and it lacks access to the library of [custom expressions](../questions/query-builder/expressions-list.md). Metabot is also limited to single-level aggregation and grouping, so if you need more complex analysis, you can take over and refine the query yourself, or switch to the SQL editor.
@@ -98,3 +98,19 @@ When you get an error in a SQL query, you can click the **Have Metabot fix it**
If Metabot creates a query or takes you to a new item but you want to return to the previous screen, you can navigate using your browser's back button.
You can also save any chart that Metabot creates to a dashboard or collection.
## Giving feedback on Metabot responses
When you hover over Metabot's responses, you'll see options to give feedback.
Thumbs up are just as helpful as thumbs down. If you give a thumbs down, you can optionally provide more details about the issue and set an issue type:
- UI bug
- Took incorrect actions
- Overall refusal
- Did not follow request
- Not factually correct
- Incomplete response
- Other
> When you submit feedback, the form you send may contain sensitive data from your conversation.

View File

@@ -1,9 +1,9 @@
---
title: Metabot settings
title: Metabot AI settings
summary: Configure Metabot settings, including which collections Metabot can access, and learn tips for improving Metabot's performance through data modeling and metadata.
---
# Metabot settings
# Metabot AI settings
{% include beta-blockquote.html
message="For now, <a href='https://www.metabase.com/features/metabot-ai'>Metabot</a> is only available as an add-on for Pro and Enterprise plans on Metabase Cloud."
@@ -13,21 +13,26 @@ _Settings > Admin settings > AI_
This page covers admin settings for Metabase's AI assistant, [Metabot](./metabot.md).
## Configure Metabot
To help Metabot focus on the data you care about most, select the collection containing the models and metrics you'd like it to work with.
## Pick a collection for Metabot to have access to
![Admin settings for AI Metabot](./images/ai-settings.png)
To select a collection for Metabot:
## Verified content
Admins on Pro and Enterprise plans can tell Metabot to only work with [models](../data-modeling/models.md) and [metrics](../data-modeling/metrics.md) that have been [verified](../exploration-and-organization/content-verification.md).
Restricting Metabot to verified models and metrics (and only models and metrics) can help Metabot produce more reliable answers, since you know someone has at least vetted the data Metabot can use.
## When embedding Metabot, you can pick a collection for Metabot to have access to
When embedding Metabot in your app, you can select a collection for Metabot:
1. Go to **Settings** > **Admin settings** > **AI**.
2. Click the **Collection** dropdown.
3. Select the collection that contains the models and metrics you want Metabot to use.
2. Click **Embedded Metabot**.
3. In the **Collection Embedded Metabot can use** section, click **Pick a collection**.
4. Select the collection that contains the models and metrics you want Metabot to use.
Metabot will use the models and metrics in that collection to help answer questions and generate queries. You can change this collection at any time.
Metabot will use the models and metrics in that collection to help answer questions and generate queries. You can change this collection at any time. To give Metabot access to all collections, you can set the collection to the root collection, called "Our Analytics" (the default).
Alternatively (or additionally), you can restrict Metabot to [verified content](#verified-content).
## Tips for making the most of Metabot
@@ -66,7 +71,7 @@ Metabase will try to set semantic types automatically, but you should confirm th
### Curate prompt suggestions
When you select a collection for Metabot to "learn", Metabot will suggest a series of prompts based on the content it finds in that collection. These give people a feel for the kinds of things people can ask Metabot to do.
When you select a collection for Metabot to "learn", Metabot will suggest a series of prompts based on the content it finds in that collection. These prompts just give people a feel for the kinds of things people can ask Metabot to do.
Admins can run these generated prompts to test the answers, or trash the individual prompts if they're not useful or misleading. You can also regenerate all the prompts with a click.
@@ -86,6 +91,16 @@ Under the hood, Metabase powers Metabot with a variety of generative models. For
To get the best results, we (the Metabase team) use internal benchmarks to determine which AI models Metabot should use for different tasks. And we are constantly iterating on performance, so Metabot will continue to improve over time.
## We don't collect or store the prompts you send to Metabot
## Unless you submit feedback, we don't collect or store the prompts you send to Metabot
We've intentionally limited what Metabot can do. Metabot lacks access to API keys, and it can't create assets, write data, or send data outside of your Metabase. Your questions and conversations remain private to your Metabase. We do collect some metadata to gauge and improve usage, but we don't train Metabot on your prompts or your data—because we can't see them!
We've intentionally limited what Metabot can do. Metabot lacks access to API keys, and it can't create assets, write data, or send your data outside of your Metabase. Your questions and conversations remain private to your Metabase (unless you [submit feedback](./metabot.md#giving-feedback-on-metabot-responses)). We do collect some metadata to gauge and improve usage.
### What Metabot can see
Metabot has access to your Metabase metadata and some data values to help answer your questions:
- **Table, Question, Model, Dashboard, and Metric metadata**: Metabot can see the structure and configuration of your content
- **Sample field values**: When you ask questions like "Filter everyone from Wisconsin," Metabot might check the values in the state field to understand how the data is stored (like "WI" vs "Wisconsin"). See [syncs](../databases/sync-scan.md).
- **Timeseries data**: For chart analysis, Metabot might see the timeseries data used to draw certain visualizations, depending on the chart type.
When you [submit feedback](./metabot.md#giving-feedback-on-metabot-responses), however, the form you send may contain sensitive data from your conversation.

View File

@@ -83,30 +83,6 @@ redirect_from:
},
"components" : {
"schemas" : {
"api.tiles.route-params" : {
"type" : "object",
"properties" : {
"lat-field" : {
"type" : "string"
},
"lon-field" : {
"type" : "string"
},
"x" : {
"description" : "value must be an integer.",
"type" : "integer"
},
"y" : {
"description" : "value must be an integer.",
"type" : "integer"
},
"zoom" : {
"description" : "value must be an integer.",
"type" : "integer"
}
},
"required" : [ "zoom", "x", "y", "lat-field", "lon-field" ]
},
"gsheets.response" : {
"anyOf" : [ {
"type" : "object",
@@ -1984,26 +1960,52 @@ redirect_from:
"enum" : [ "none", "global-schedule" ]
},
"metabase-enterprise.transforms.api.transform-source" : {
"type" : "object",
"properties" : {
"query" : {
"type" : "object",
"properties" : {
"database" : {
"oneOf" : [ {
"type" : "object",
"properties" : {
"query" : {
"type" : "object",
"properties" : {
"database" : {
"type" : "integer"
}
},
"required" : [ "database" ]
},
"type" : {
"const" : "query"
}
},
"required" : [ "type", "query" ]
}, {
"type" : "object",
"properties" : {
"body" : {
"type" : "string"
},
"source-database" : {
"type" : "integer"
},
"source-tables" : {
"type" : "object",
"additionalProperties" : {
"type" : "integer"
}
},
"required" : [ "database" ]
"type" : {
"const" : "python"
}
},
"type" : {
"const" : "query"
}
},
"required" : [ "type", "query" ]
"required" : [ "source-tables", "type", "body" ],
"additionalProperties" : false
} ]
},
"metabase-enterprise.transforms.api.transform-target" : {
"type" : "object",
"properties" : {
"database" : {
"type" : "integer"
},
"name" : {
"type" : "string"
},
@@ -14969,6 +14971,35 @@ redirect_from:
"tags" : [ "/api/ee/metabot-v3/document" ]
}
},
"/api/ee/metabot-v3/feedback" : {
"post" : {
"summary" : "POST /api/ee/metabot-v3/feedback",
"description" : "Proxy Metabot feedback to Harbormaster, adding the premium embedding token.",
"parameters" : [ ],
"responses" : {
"2XX" : {
"description" : "Successful response"
},
"4XX" : {
"description" : "Client error response"
},
"5XX" : {
"description" : "Server error response"
}
},
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"type" : "object",
"properties" : { }
}
}
}
},
"tags" : [ "/api/ee/metabot-v3" ]
}
},
"/api/ee/metabot-v3/metabot/" : {
"get" : {
"summary" : "GET /api/ee/metabot-v3/metabot/",
@@ -15819,6 +15850,16 @@ redirect_from:
},
"value" : {
"anyOf" : [ {
"type" : "object",
"additionalProperties" : {
"anyOf" : [ {
"type" : "string",
"minLength" : 1
}, {
"type" : "boolean"
} ]
}
}, {
"type" : "string",
"minLength" : 1
}, {
@@ -16174,7 +16215,18 @@ redirect_from:
}
}, {
"in" : "query",
"name" : "transform_tag_ids",
"name" : "last_run_statuses",
"required" : false,
"schema" : {
"type" : "array",
"items" : {
"type" : "string",
"enum" : [ "started", "succeeded", "failed", "timeout" ]
}
}
}, {
"in" : "query",
"name" : "tag_ids",
"required" : false,
"schema" : {
"type" : "array",
@@ -16496,7 +16548,38 @@ redirect_from:
"get" : {
"summary" : "GET /api/ee/transform/",
"description" : "Get a list of transforms.",
"parameters" : [ ],
"parameters" : [ {
"in" : "query",
"name" : "last_run_start_time",
"required" : false,
"schema" : {
"type" : "string",
"minLength" : 1
}
}, {
"in" : "query",
"name" : "last_run_statuses",
"required" : false,
"schema" : {
"type" : "array",
"items" : {
"type" : "string",
"enum" : [ "started", "succeeded", "failed", "timeout" ]
}
}
}, {
"in" : "query",
"name" : "tag_ids",
"required" : false,
"schema" : {
"type" : "array",
"items" : {
"description" : "value must be an integer greater or equal to than zero.",
"type" : "integer",
"minimum" : 0
}
}
} ],
"responses" : {
"2XX" : {
"description" : "Successful response"
@@ -17607,11 +17690,18 @@ redirect_from:
"tags" : [ "/api/embed" ]
}
},
"/api/embed/tiles/card/{token}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/embed/tiles/card/{token}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/embed/tiles/card/{token}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/embed/tiles/card/{token}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for an embedded Card using the map visualization.",
"parameters" : [ {
"in" : "path",
"name" : "token",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
@@ -17635,27 +17725,6 @@ redirect_from:
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "token",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "parameters",
@@ -17664,6 +17733,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -17679,49 +17762,11 @@ redirect_from:
"tags" : [ "/api/embed" ]
}
},
"/api/embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a Card on an embedded Dashboard using the map visualization.",
"parameters" : [ {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "token",
"required" : true,
@@ -17746,6 +17791,30 @@ redirect_from:
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "query",
"name" : "parameters",
@@ -17754,6 +17823,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -20763,11 +20846,18 @@ redirect_from:
"tags" : [ "/api/preview_embed" ]
}
},
"/api/preview_embed/tiles/card/{token}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/preview_embed/tiles/card/{token}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/preview_embed/tiles/card/{token}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/preview_embed/tiles/card/{token}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for an embedded Card using the map visualization.",
"parameters" : [ {
"in" : "path",
"name" : "token",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
@@ -20791,27 +20881,6 @@ redirect_from:
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "token",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "parameters",
@@ -20820,6 +20889,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -20835,49 +20918,11 @@ redirect_from:
"tags" : [ "/api/preview_embed" ]
}
},
"/api/preview_embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/preview_embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/preview_embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/preview_embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a Card on an embedded Dashboard using the map visualization.",
"parameters" : [ {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "token",
"required" : true,
@@ -20902,6 +20947,30 @@ redirect_from:
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "query",
"name" : "parameters",
@@ -20910,6 +20979,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -21805,11 +21888,20 @@ redirect_from:
"tags" : [ "/api/public" ]
}
},
"/api/public/tiles/card/{uuid}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/public/tiles/card/{uuid}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/public/tiles/card/{uuid}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/public/tiles/card/{uuid}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a publicly-accessible Card using the map visualization. Does not require auth\n credentials. Public sharing must be enabled.",
"parameters" : [ {
"in" : "path",
"name" : "uuid",
"required" : true,
"schema" : {
"type" : "string",
"pattern" : "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
},
"description" : "value must be a valid UUID."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
@@ -21833,29 +21925,6 @@ redirect_from:
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "uuid",
"required" : true,
"schema" : {
"type" : "string",
"pattern" : "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
},
"description" : "value must be a valid UUID."
}, {
"in" : "query",
"name" : "parameters",
@@ -21864,6 +21933,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -21879,49 +21962,11 @@ redirect_from:
"tags" : [ "/api/public" ]
}
},
"/api/public/tiles/dashboard/{uuid}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/public/tiles/dashboard/{uuid}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/public/tiles/dashboard/{uuid}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/public/tiles/dashboard/{uuid}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a Card using the map visualization in a publicly-accessible Dashboard. Does not\n require auth credentials. Public sharing must be enabled.",
"parameters" : [ {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "uuid",
"required" : true,
@@ -21948,6 +21993,30 @@ redirect_from:
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "query",
"name" : "parameters",
@@ -21956,6 +22025,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -24350,11 +24433,20 @@ redirect_from:
"tags" : [ "/api/task" ]
}
},
"/api/tiles/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/tiles/{card-id}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/tiles/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/tiles/{card-id}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a saved Card.",
"parameters" : [ {
"in" : "path",
"name" : "card-id",
"required" : true,
"schema" : {
"type" : "integer",
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
@@ -24378,29 +24470,6 @@ redirect_from:
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "card-id",
"required" : true,
"schema" : {
"type" : "integer",
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "query",
"name" : "parameters",
@@ -24409,6 +24478,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -24424,49 +24507,11 @@ redirect_from:
"tags" : [ "/api/tiles" ]
}
},
"/api/tiles/{dashboard-id}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/tiles/{dashboard-id}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/tiles/{dashboard-id}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/tiles/{dashboard-id}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a dashcard.",
"parameters" : [ {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "dashboard-id",
"required" : true,
@@ -24493,6 +24538,30 @@ redirect_from:
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "query",
"name" : "parameters",
@@ -24501,6 +24570,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -24516,11 +24599,35 @@ redirect_from:
"tags" : [ "/api/tiles" ]
}
},
"/api/tiles/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/tiles/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/tiles/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/tiles/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for an ad-hoc query.",
"parameters" : [ {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "query",
"name" : "query",
"required" : true,
@@ -24528,6 +24635,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {

View File

@@ -122,11 +122,12 @@ If you have databases connected to Metabase that are set to **Use default** poli
To set a caching policy for a dashboard, you must have [curate access](../permissions/collections.md#curate-access) to the dashboard's collection.
1. Go to your dashboard.
2. Click on the **info** icon.
3. Click **Caching policy**.
4. Select the [caching invalidation policy](#cache-invalidation-policies).
5. Optional: turn on [refresh cache automatically](#refresh-cache-automatically).
6. Save your changes.
2. Click on the **three dots** icon in the top-right corner of the dashboard and choose **Edit settings**.
3. By default, each question will use the database default caching settings. Click the current caching policy to change it.
4. Select the new [caching policy](#cache-invalidation-policies).
5. Optional: turn on [refresh cache automatically](#refresh-cache-automatically)
6. Optional: to clear the cache for all questions on a dashboard, click **Clear cache for this dashboard** at the bottom of the setting sidebar.
7. Save your changes.
### Question caching policy

View File

@@ -188,6 +188,7 @@ config:
ldap-security: none
ldap-sync-user-attributes: true
ldap-sync-user-attributes-blacklist: userPassword,dn,distinguishedName
ldap-timeout-seconds: 15.0
ldap-user-base: null
ldap-user-filter: (&(objectClass=inetOrgPerson)(|(uid={login})(mail={login})))
ldap-user-provisioning-enabled: true

View File

@@ -1058,6 +1058,14 @@ Should we sync user attributes when someone logs in via LDAP?
Comma-separated list of user attributes to skip syncing for LDAP users.
### `MB_LDAP_TIMEOUT_SECONDS`
- Type: double
- Default: `15.0`
- [Configuration file name](./config-file.md): `ldap-timeout-seconds`
Maximum time, in seconds, to wait for LDAP server before falling back to local authentication.
### `MB_LDAP_USER_BASE`
- Type: string

View File

@@ -19,7 +19,7 @@ To manage upload settings, admins can hit cmd/ctrl + K and search for "Settings
- [PostgreSQL](../databases/connections/postgresql.md)
- [MySQL](../databases/connections/mysql.md)
- [Redshift](../databases/connections/redshift.md)
- [ClickHouse](../databases/connections/clickhouse.md)
- [ClickHouse](../databases/connections/clickhouse.md) (only supported on ClickHouse Cloud)
## Setting up uploads

View File

@@ -29,6 +29,8 @@ title: Driver interface changelog
- Added `metabase.driver/type->database-type` multimethod that returns the database type for a given Metabase
type (from the type hierarchy) as a HoneySQL spec. This method handles general Metabase base types.
- Added driver multimethods driver/native-result-metadata, driver/validate-native-query-fields, driver.sql/resolve-field, driver.sql.normalize-unquoted-name, driver.sql.normalize/reserved-literal, driver.sql.references/find-used-fields, driver.sql.references/find-returned-fields, and driver.sql.references/field-references-impl for use with the :dependencies/native feature.
- Added `metabase.driver/insert-from-source!` multimethod that abstracts data insertion from various sources
into existing tables. This multimethod dispatches on both the driver and the data source type
(`:rows` or `:jsonl-file`). It allows drivers to optimize based on the data source type and returns the number

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

@@ -42,9 +42,9 @@ If however, you delete the document's copy, that copy is permanently deleted. It
### Charts generated by Metabot only exist in the document
If Metabot generates a chart, Metabase saves that question to the document itself. If you delete it, it's gone forever. You won't be able to recover the question from the trash.
If [Metabot](../ai/metabot.md) generates a chart, Metabase saves that question to the document itself. If you delete it, it's gone forever. You won't be able to recover the question from the trash.
### Editing charts
## Editing charts
To edit a chart, click the **three-dot** menu in the chart's upper right. You can:
@@ -54,6 +54,22 @@ To edit a chart, click the **three-dot** menu in the chart's upper right. You ca
Edits only affect the chart in the document. If you added the chart from a collection, Metabase will have created a copy of the chart. Any changes made to the chart in the document won't affect the original question.
## Arranging charts
![Document layout](./images/document-layout.png)
To rearrange charts, simply click and hold on the chart and drag it to where you want it in the document.
You can put up to three charts side by side (more than three charts side-by-side gets crowded). To resize charts, you can click on the edges to adjust their size.
When there are multiple charts in the same row, you can resize them horizontally by clicking between the charts and sliding left or right to resize.
To resize a chart's height, click the bottom of the chart and resize the chart up or down.
For now, you can only put charts side-by-side (not text elements).
After arranging your charts, save your changes!
## Formatting text
Documents support Markdown syntax for:
@@ -71,9 +87,13 @@ If you paste a link into a document, Metabase will turn it into a clickable link
## Commenting on documents
To comment on a document, click the chat bubble next to the section you want to comment on.
To comment on a text element, click the chat bubble next to the section. You can only comment on whole sections; you can't highlight text to comment on.
You can comment on whole sections like charts, headings, or text blocks. You can't highlight portions of text to comment on.
To comment on a chart:
1. Hover over the chart.
2. Click the three-dot menu.
3. Select **Comment**.
You can:
@@ -84,7 +104,9 @@ You can:
### Comment notifications
If someone mentions you in a thread, or responds to a comment in a thread you're in, Metabase will send you an email to let you know. Currently, there's no central place to view all of your threads and mentions, and no way to disable email notifications for comments.
If someone mentions you in a thread, or responds to a comment in a thread you're in, Metabase will send you an email to let you know.
Currently, there's no central place to view all of your threads and mentions, and no way to disable email notifications for comments.
### Deleting comments

View File

@@ -9,6 +9,8 @@ summary: Getting started with Embedded Analytics JS for embedding Metabase entit
Embedded analytics JS lets you embed Metabase entities like questions, dashboards, or even the query builder into your own application using customizable components.
{% include shared/in-page-promo-embedding-workshop.html %}
Embedded Analytics JS is a JavaScript library built on top of Metabase's [Embedded Analytics React SDK](./sdk/introduction.md). But it does not require using React or setting up full SDK embedding.
Unlike with [interactive embedding](./interactive-embedding.md), where you embed the entire Metabase app in an iframe, Embedded Analytics JS lets you choose from a set of predefined components like a single chart, a dashboard with optional drill-through, or query builder, and customize those components.
@@ -23,20 +25,25 @@ Currently you can choose to embed:
## Quickstart
You can also follow the setup guide directly in Metabase in **Admin > Embedding > Setup guide**. We're recording the steps here for convenience.
### 1. Enable Embedded Analytics JS
1. In Metabase, go to **Admin Settings > Embedding > Modular embedding**.
1. In Metabase, go to **Admin > Embedding > Modular embedding**.
2. Toggle on **Embedded Analytics JS**.
3. Under **Cross-Origin Resource Sharing (CORS)**, add the URLs of the websites where you want to embed Metabase (such as `https://*.example.com`). For testing embeds, you can use `localhost` which is always included in CORS policy.
### 2. Create a new embed
1. In Metabase, go to **Admin Settings > Embedding > Quick start** and select **Create embed**. Note that this will only be visible to admins.
1. In Metabase, go to **Admin > Embedding > Setup guide > Embed in your code**, and select **Try it out** next to **Embedded analytics JS**.
If you're planning to embed an existing question or dashboard, you can instead go straight to that question or dashboard, click on the **Share** button, and choose **Embedded Analytics JS**.
2. Choose the _type_ of entity to embed:
- Dashboard
- Question
- Exploration (which will embed the Metabase query builder)
- Browser
- Dashboard
- Question
- Exploration (which will embed the Metabase query builder)
- Browser
3. Next, select the entity you want to embed. For browser, pick the collection you want people to start from.
Once you've selected what you want to embed, click Next to customize your embed.
@@ -109,13 +116,13 @@ Note the `defer` attribute and the reference to your Metabase URL in the script
If you're embedding multiple entities on the same page, you only need to include the `<script>` tags once globally.
You can also generate the code snippet for Embedded Analytics JS interactively in Metabase through **Admin Settings > Embedding > Setup guide > Create embed**. Check out the [Quickstart](#quickstart).
You can also generate the code snippet for Embedded Analytics JS interactively in Metabase through **Admin > Embedding > Setup guide > Embed in your code**. Check out the [quickstart](#quickstart).
## Customizing embeds
The exact customization options you see will depend on what type of entity you're embedding.
When you're creating a new embed using **Admin Settings > Embedding > Setup guide > Create embed**, you'll see the following customization options in the interactive creation flow. These options correspond to parameters in [components](#components).
When you're creating a new embed using **Admin > Embedding > Setup guide > Embed in your code**, you'll see the following customization options in the interactive creation flow. These options correspond to parameters in [components](#components).
- **Allow people to drill through on data points**: determines whether people can interact with the chart (or charts on a dashboard). Interactivity includes [drilling down](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) to individual records from aggregated questions, filtering on click, zooming in, etc. Disabling drill-through for an embedded _question_ also disables people's ability to add filters and summaries.
@@ -141,6 +148,8 @@ To define the configuration that applies to every embed on the page, use the `de
- `apiKey: mb_YourAPIKey` (optional, for development only) - another way to preview embeds locally using an API key.
- `fetchRequestToken: () => Promise<{ jwt: string }>` (optional) - you can customize how the SDK fetches the refresh token for JWT authentication by specifying the `fetchRequestToken` function. See [customizing JWT authentication](./sdk/authentication.md#customizing-jwt-authentication).
### Theming
You can specify colors, fonts, spacing, and other appearance options using the `theme` parameter in your embed configuration.
@@ -360,7 +369,7 @@ To render a question (chart).
To render a collection browser so people can navigate a collection and open dashboards or questions.
```html
```html
<metabase-browser initial-collection="14" read-only="false"></metabase-browser>
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -29,9 +29,9 @@ You could also use the dashboard's [Entity ID](../installation-and-operation/ser
### Enable interactive embedding
In Metabase, click on the **gear** icon in the upper right and go to **Admin settings** > **Settings** > **Embedding**. Look for **Interactive Embedding** and click on the **Enabled** toggle.
In Metabase, click on the **gear** icon in the upper right and go to **Admin > Embedding > Interactive Embedding** and toggle on **Enable interactive embedding**.
Click on the **Configure** button under **Interactive Embedding**. Under **Authorized origins**, add the URL of the website or web app where you want to embed Metabase. If you're running your app locally, you can add localhost and specify the port number, e.g. `http://localhost:8080`.
Under **Authorized origins**, add the URL of the website or web app where you want to embed Metabase. If you're running your app locally, you can add localhost and specify the port number, e.g. `http://localhost:8080`.
#### SameSite configuration
@@ -41,7 +41,7 @@ If you're embedding Metabase in a different domain, you may need to [set the ses
#### Enable authentication with JWT
While still in the Admin panel's **Settings** section, click on **Authentication**.
While still in the **Interactive embedding** section, click on **Authentication** under **Related settings**.
On the card that says **JWT**, click the **Setup** button (you may have to scroll down to view the JWT card).
@@ -169,7 +169,7 @@ In Metabase, click the **gear** icon and go to **Admin settings** > **People** >
You'll map this string in the `groups` key to a Metabase group, so that when the person signs in via SSO, Metabase automatically assigns them to the appropriate Metabase group.
In Metabase's admin section, go to **Settings** > **Authentication**. Scroll to the **JWT** card and click **Edit**.
In Metabase's admin section, go to **Authentication > JWT** and click **Edit**.
In the **Group schema** section, toggle on **Synchronize group memberships**. If the names of groups in the `groups` array match Metabase group names exactly (e.g. both are `"Customer Acme"`), then the groups will be mapped automatically.

View File

@@ -11,12 +11,10 @@ redirect_from:
{% include shared/in-page-promo-embedding-workshop.html %}
**Interactive embedding** is what you want if you want to offer [multi-tenant, self-service analytics](https://www.metabase.com/learn/metabase-basics/embedding/multi-tenant-self-service-analytics).
**Interactive embedding** lets you embed the entire Metabase app in an iframe. Interactive embedding integrates your [permissions](../permissions/introduction.md) and [SSO](../people-and-groups/start.md#authentication) to give people the right level of access to [query](../questions/query-builder/editor.md) and [drill-down](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) into your data.
> If you are just starting out with Metabase embedding, consider using [Embedded Analytics JS](./embedded-analytics-js.md) instead of interactive embedding - it's an improved, more customizable option for embedding interactive Metabase elements. Interactive embedding remains fully supported.
Interactive embedding is the only type of embedding that integrates with your [permissions](../permissions/introduction.md) and [SSO](../people-and-groups/start.md#authentication) to give people the right level of access to [query](https://www.metabase.com/glossary/query-builder) and [drill-down](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) into your data.
## Interactive embedding demo
To get a feel for what you can do with interactive embedding, check out our [interactive embedding demo](https://www.metabase.com/embedding-demo).
@@ -40,10 +38,9 @@ If you have your app running locally, and you're using the Pro Cloud version, or
## Enabling interactive embedding in Metabase
1. Go to **Settings** > **Admin settings** > **Embedding**.
2. Click **Enable**.
3. Click **Interactive embedding**.
4. Under **Authorized origins**, add the URL of the website or web app where you want to embed Metabase (such as `https://*.example.com`).
1. Go to **Admin > Embedding > Interactive**.
2. Click **Enable interactive embedding**.
3. Under **Authorized origins**, add the URL of the website or web app where you want to embed Metabase (such as `https://*.example.com`).
## Setting up embedding on your website
@@ -131,7 +128,7 @@ Note that your interactive embed must be compatible with Safari to run on _any_
If you want to embed Metabase in another domain (say, if Metabase is hosted at `metabase.yourcompany.com`, but you want to embed Metabase at `yourcompany.github.io`), you can tell Metabase to set the session cookie's SameSite value to "none".
You can set session cookie's SameSite value in **Admin settings** > **Embedding** > **Interactive embedding** > **SameSite cookie setting**.
You can set session cookie's SameSite value in **Admin settings** > **Embedding** > **Interactive** > **SameSite cookie setting**.
SameSite values include:

View File

@@ -12,12 +12,6 @@ Here are the different ways you can embed Metabase.
{% include shared/in-page-promo-embedding-workshop.html %}
## Embedded analytics SDK with React
With the [Embedded analytics SDK](./sdk/introduction.md), you can embed individual Metabase components with React (like standalone charts, dashboards, the query builder, and more). You can manage access and interactivity per component, and you have advanced customization for seamless styling.
**When to use the Embedded analytics SDK**: you want the most control over how you embed Metabase in your React app.
## Embedded analytics JS
With [Embedded analytics JS](./embedded-analytics-js.md), you can embed individual Metabase components in your web app with JavaScript — no React required. Choose from dashboards, questions, or the query builder, and configure per‑component options like drill‑through, parameters, downloads, and theming. Embedded Analytics JS integrates with [SSO](securing-embeds.md) and [data permissions](../permissions/embedding.md).
@@ -42,22 +36,27 @@ If you'd like to share your data with the good people of the internet, admins ca
## Comparison of embedding types
| Action | [SDK](./sdk/introduction.md) | [JS](./embedded-analytics-js.md) | [Interactive](./interactive-embedding.md) | [Static](./static-embedding.md) | [Public](../embedding/public-links.md) |
| ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | -------------------------------- | ----------------------------------------- | ------------------------------- | -------------------------------------- |
| Display charts and dashboards | ✅ | ✅ | ✅ | ✅ | ✅ |
| Display interactive [filter widgets](https://www.metabase.com/glossary/filter-widget) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Export results\* | ✅ | ✅ | ✅ | ✅ | ✅ |
| Restrict data with [locked filters](./static-embedding-parameters.md#restricting-data-in-a-static-embed-with-locked-parameters) | ❌ | ❌ | ❌ | ✅ | ❌ |
| Restrict data with [row and column security](../permissions/row-and-column-security.md) | ✅ | ✅ | ✅ | ❌ | ❌ |
| Use the [drill-through menu](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) | ✅ | ✅ | ✅ | ❌ | ❌ |
| Self-serve via [query builder](https://www.metabase.com/glossary/query-builder) | ✅ | ✅ | ✅ | ❌ | ❌ |
| View usage of embeds with [usage analytics](../usage-and-performance-tools/usage-analytics.md) | ✅ | ✅ | ✅ | ❌ | ❌ |
| [Actions on dashboards](../dashboards/actions.md) | ✅ | ✅ | ✅ | ❌ | ❌ |
| Embed individual Metabase components | ✅ | ✅ | ❌ | ❌ | ❌ |
| Manage access and interactivity per component | ✅ | ✅ | ❌ | ❌ | ❌ |
| Action | [React SDK](./sdk/introduction.md) | [JS](./embedded-analytics-js.md) | [Interactive](./interactive-embedding.md) | [Static](./static-embedding.md) | [Public](../embedding/public-links.md) |
| ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | -------------------------------- | ----------------------------------------- | ------------------------------- | -------------------------------------- |
| Display charts and dashboards | ✅ | ✅ | ✅ | ✅ | ✅ |
| Display interactive [filter widgets](https://www.metabase.com/glossary/filter-widget) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Export results\* | ✅ | ✅ | ✅ | ✅ | ✅ |
| Restrict data with [locked filters](./static-embedding-parameters.md#restricting-data-in-a-static-embed-with-locked-parameters) | ❌ | ❌ | ❌ | ✅ | ❌ |
| [Data segregation](../permissions/embedding.md) | ✅ | ✅ | ✅ | ❌ | ❌ |
| Use the [drill-through menu](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) | ✅ | ✅ | ✅ | ❌ | ❌ |
| Self-serve via [query builder](../questions/query-builder/editor.md) | ✅ | ✅ | ✅ | ❌ | ❌ |
| [Basic appearance customization](../configuring-metabase/appearance.md)\*\* | ✅ | ✅ | ✅ | ✅ | ✅ |
| [Advanced theming](./sdk/appearance.md) | ✅ | ✅ | ❌ | ❌ | ❌ |
| View usage of embeds with [usage analytics](../usage-and-performance-tools/usage-analytics.md) | ✅ | ✅ | ✅ | ❌ | ❌ |
| Embed individual Metabase components | ✅ | ✅ | ❌ | ❌ | ❌ |
| Manage access and interactivity per component | ✅ | ✅ | ❌ | ❌ | ❌ |
| Custom layouts | ✅ | ❌ | ❌ | ❌ | ❌ |
| Customize behavior with [plugins](./sdk/plugins.md) | ✅ | ❌ | ❌ | ❌ | ❌ |
\* Each embedding type allows data downloads by default, but only [Pro and Enterprise](https://www.metabase.com/pricing/) plans can disable data downloads.
\*\* Requires a [Pro and Enterprise](https://www.metabase.com/pricing/) plan for any embedding type.
### Embedded analytics SDK vs JS
When deciding between the Embedded analytics SDK and Embedded analytics JS: if your app uses React, you should use the SDK. Otherwise, use the JS library. The JS library uses the SDK under the hood, but you can have more control with React and the SDK.

View File

@@ -65,9 +65,9 @@ You can simulate drill-through on a public dashboard by setting up a [custom cli
6. Optional: pass a filter value from the primary dashboard to the destination dashboard by adding a query parameter to the end of the destination URL:
```
{%raw%}
{% raw %}
/public/dashboard/?child_filter_name={{parent_column_name}}
{%endraw%}
{% endraw %}
```
For example, if you have a primary public dashboard that displays **Invoices** data, you can pass the **Plan** name (on click) to a destination public dashboard that displays **Accounts** data:

View File

@@ -24,9 +24,11 @@ You can check out a [demo of the AI chat component](https://embedded-analytics-s
```typescript
{% include_file "{{ dirname }}/snippets/questions/ai-question.tsx" %}
```
## API reference
- [Component](./api/MetabotQuestion.html)
- [Props](./api/MetabotQuestionProps.html)
## Setting up AI chat
@@ -40,3 +42,11 @@ To configure your embedded AI chat in your Metabase:
When embedding the Metabot component in your app, you should specify a collection that embedded Metabot is allowed to use for creating queries. Embedded Metabot will only have access to that collection.
For tips and more, see [Metabot settings](../../ai/settings.md).
## Layout
Use the `layout` prop to specify which layout to use for the Metabot component:
- `auto` (default): Metabot uses the `stacked` layout on mobile screens, and a `sidebar` layout on larger screens.
- `stacked`: the question visualization stacks on top of the chat interface.
- `sidebar`: the question visualization appears to the left of the chat interface, which is on a sidebar on the right.

View File

@@ -89,7 +89,7 @@ You can add some middleware in your backend to handle cross-domain requests.
## Customizing JWT authentication
You can customize how the SDK fetches the refresh token by specifying the `fetchRefreshToken` function with the `defineMetabaseAuthConfig` function:
You can customize how the SDK fetches the request token by specifying the `fetchRequestToken` function with the `defineMetabaseAuthConfig` function:
```typescript
{% include_file "{{ dirname }}/snippets/authentication/auth-config-jwt.tsx" snippet="example" %}

View File

@@ -36,7 +36,9 @@ To use the SDK, you'll need to enable the SDK in Metabase, and install the SDK i
### Enable the SDK in Metabase
Enable the Embedded analytics SDK by going to **Admin settings > Settings > Embedding**. Toggle on the SDK, and hit **Configure**. Enter the origins for your website or app where you want to allow SDK embedding, separated by a space. Localhost is automatically included.
1. Enable the Embedded analytics SDK by going to **Admin settings > Embedding > Modular**.
2. Toggle on **SDK for React**.
3. In **Cross-Origin Resource Sharing (CORS)**, enter the origins for your website or app where you want to allow SDK embedding, separated by a space. Localhost is automatically included.
### Install the SDK in your React application

View File

@@ -98,11 +98,11 @@ To enable SSO with JWT when self-hosting, you'll need to [activate your license]
## Enable embedding in Metabase
From any Metabase page, click on the **gear** icon in the upper right and select **Admin Settings** > **Settings** > **Embedding**.
From any Metabase page, click on the **gear** icon in the upper right and select **Admin Settings** > **Embedding**.
Turn on:
- Embedded analytics SDK
- Embedded analytics SDK (it's in in the **Modular** section)
- Static embedding
Otherwise, this whole thing is hopeless.

View File

@@ -36,11 +36,11 @@ To embed a dashboard in your app using the SDK, you'll need to:
## 1. Enable the SDK in Metabase
In Metabase, click on the gear icon in the upper right and navigate to **Admin Settings > Settings > Embedding** and enable the Embedded analytics SDK.
In Metabase, click on the gear icon in the upper right and navigate to **Admin Settings > Embedding > Modular** and enable the **SDK for React**.
## 2. Create an API key in Metabase
Still in the Admin's Settings tab, navigate to the **Authentication** section in the sidebar and click on the **API keys** tab. [Create a new API key](../../people-and-groups/api-keys.md).
Still in the Admin console, go to **Settings > Authentication** and click on the **API keys** tab. [Create a new API key](../../people-and-groups/api-keys.md).
- Key name: "Embedded analytics SDK" (just to make the key easy to identify).
- Group: select “Admin” (since this is only for local testing).

View File

@@ -30,8 +30,7 @@ Metabase Cloud upgrades your instance automatically as new versions roll out. Bu
To manually pin your version of Metabase:
1. Go to **Admin settings > Settings > Embedding**.
2. Go to the Embedded analytics SDK card.
3. Scroll to **Version pinning** and click **Request version pinning**.
1. Go to **Admin > Embedding > Modular**.
2. Scroll to **Version pinning** and click **Request version pinning**.
This will open a mailto link to our support team.

View File

@@ -10,25 +10,13 @@ redirect_from:
What is embedding, and how does it work?
## [Interactive embedding](./interactive-embedding.md)
The solution to self-service customer analytics: embed the full Metabase app in your app. Interactive embedding integrates with your data permissions to let people slice and dice data on their own using Metabase's query builder.
## [Interactive embedding quickstart](./interactive-embedding-quick-start-guide.md)
You'll embed the full Metabase application in your app. Once logged in, people can view a Metabase dashboard in your web app, and be able to use the full Metabase application to explore their data, and only their data.
## [Interactive UI components](./interactive-ui-components.md)
Customize the UI components in your interactive embed by adding parameters to the embedding URL.
## [Embedded analytics SDK](./sdk/introduction.md)
With the Embedded analytics SDK, you can embed individual Metabase components with React (like standalone charts, dashboards, the query builder, and more). You can manage access and interactivity per component, and you have advanced customization for seamless styling.
## [Embedded analytics JS](./embedded-analytics-js.md)
Embed dashboards, questions, or the query builder in your app with JavaScript (no React required). Built on the Embedded analytics SDK with per-component controls and theming.
Embed dashboards, questions, or the query builder in your app with JavaScript (no React required). Built on the Embedded analytics SDK with per-component controls and theming. Integrate your app's auth with Metabase SSO.
## [Embedded analytics SDK quickstart](./sdk/quickstart.md)
@@ -42,6 +30,18 @@ Also known as Signed Embedding, Static embedding is a secure way to embed charts
You can pass parameters between Metabase and your website via the embedding URL to specify how Metabase items should look and behave inside the iframe on your website.
## [Interactive embedding](./interactive-embedding.md)
Interactive embedding allows you to embed full Metabase app in an iframe. Interactive embedding integrates with your data permissions to let people slice and dice data on their own using Metabase's query builder.
## [Interactive embedding quickstart](./interactive-embedding-quick-start-guide.md)
You'll embed the full Metabase application in your app. Once logged in, people can view a Metabase dashboard in your web app, and be able to use the full Metabase application to explore their data, and only their data.
## [Interactive UI components](./interactive-ui-components.md)
Customize the UI components in your interactive embed by adding parameters to the embedding URL.
## [Public embeds](./public-links.md)
Admins can also create unsecured public links or embeds of questions and dashboards.

View File

@@ -12,7 +12,7 @@ Also known as: standalone embedding, or signed embedding.
In general, embedding works by displaying a Metabase URL inside an iframe in your website. A **static embed** (or signed embed) is an iframe that's loading a Metabase URL secured with a signed JSON Web Token (JWT). Metabase will only load the URL if the request supplies a JWT signed with the secret shared between your app and your Metabase. The JWT also includes a reference to the resource to load, e.g., the dashboard ID, and any values for locked parameters.
You can't use static embeds with [row and column security](../permissions/row-and-column-security.md), [drill-through](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through), and user-specific data isn't captured in [usage analytics](../usage-and-performance-tools/usage-analytics.md) because signed JWTs don't create user sessions (server-side sessions). For those features, check out [interactive embedding](./interactive-embedding.md).
You can't use static embeds with [row and column security](../permissions/row-and-column-security.md), [drill-through](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through), and user-specific data isn't captured in [usage analytics](../usage-and-performance-tools/usage-analytics.md) because signed JWTs don't create user sessions (server-side sessions). For those features, check out [Embedded analytics JS](./embedded-analytics-js.md).
You can, however, restrict data in static embeds for specific people or groups by [locking parameters](./static-embedding-parameters.md#restricting-data-in-a-static-embed-with-locked-parameters).
@@ -34,12 +34,12 @@ your_metabase_embedding_url/your_signed_jwt?filter=true
The signed JWT is generated using your [Metabase secret key](#regenerating-the-static-embedding-secret-key). The secret key tells Metabase that the request for filtered data can be trusted, so it's safe to display the results at the new embedding URL. Note that this secret key is shared for all static embeds, so whoever has access to that key will have access to all embedded artifacts.
If you want to embed charts with additional interactive features, like [drill-down](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) and [self-service querying](../questions/query-builder/editor.md), see [Interactive embedding](./interactive-embedding.md).
If you want to embed charts with additional interactive features, like [drill-down](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) and [self-service querying](../questions/query-builder/editor.md), see [Embedded analytics JS](./embedded-analytics-js.md).
## Turning on the embedding feature in Metabase
1. Go to **Settings** > **Admin settings** > **Embedding**.
2. Toggle the **Enable embedding**.
1. Go to **Settings** > **Admin settings** > **Embedding > Static**.
2. Toggle **Enable static embedding**.
## Making a question or dashboard embeddable
@@ -127,7 +127,7 @@ If you change the [parameters](./static-embedding-parameters.md) of your embedde
## Disabling embedding for a question or dashboard
You can find a list of all static embeds of questions and dashboards from **Admin settings** > **Embedding** > **Static embedding** > **Manage**.
You can find a list of all static embeds of questions and dashboards from **Admin settings** > **Embedding** > **Static**.
1. Visit the embeddable question or dashboard.
2. Click on the **sharing icon** (square with an arrow pointing to the top right).
@@ -164,9 +164,8 @@ The banner appears on static embeds created with Metabase's open-source version.
Your embedding secret key is used to sign JWTs for all of your [embedding URLs](#adding-the-embedding-url-to-your-website).
1. Go to **Settings** > **Admin settings** > **Embedding**.
2. On the **Static embedding** card, click on **Manage**.
3. Under **Regenerate secret key**, click **Regenerate key**.
1. Go to **Admin** > **Embedding** > **Static embedding**.
2. Under **Regenerate secret key**, click **Regenerate key**.
This key is shared across all static embeds. Whoever has access to this key could get access to all embedded artifacts, so keep this key secure. If you regenerate this key, you'll need to update your server code with the new key.

View File

@@ -7,7 +7,7 @@ summary: Upload a translation dictionary to translate questions and dashboards i
{% include plans-blockquote.html feature="Translation of embedded content" convert_pro_link_to_embbedding=true %}
For now, translations are only available for [static embeds](./static-embedding.md), not Interactive embedding or the Embedded analytics SDK.
For now, translations are only available for [static embeds](./static-embedding.md), not Interactive embedding or the Embedded analytics JS/SDK.
You can upload a translation dictionary to translate strings both in Metabase content (like dashboard titles) and in the data itself (like column names and values).
@@ -21,6 +21,11 @@ The dictionary must be a CSV with these columns:
> Don't put any sensitive data in the dictionary, since anyone can see the dictionary—including viewers of public links.
To add a translation dictionary:
1. Go to **Admin > Embedding > Static**.
2. Under **Translate embedded dashboards and question**, click **Upload translation dictionary**.
Uploading a new dictionary will replace the existing dictionary.
To remove a translation dictionary, upload a blank dictionary.
@@ -64,9 +69,9 @@ Exact translations also apply to strings that use punctuation and special charac
Translations are case-insensitive. For example, if your translation dictionary has a translation:
| Language | String | Translation |
| -------- | ----------- | ------------ |
| pt-BR | Examples | Exemplos |
| Language | String | Translation |
| -------- | -------- | ----------- |
| pt-BR | Examples | Exemplos |
`Examples`, `examples`, `EXAMPLES` would all be translated as `Exemplos`. That is, Metabase won't preserve the original case; it will output the exact translation string in the dictionary.

View File

@@ -25,7 +25,7 @@ You can use the command palette to:
So anytime you want to do or find anything in Metabase, just hit `cmd/ctrl + k` and start typing what you want to do.
> The command palette is currently unavailable in [interactive embedding](../embedding/interactive-embedding.md) contexts.
> The command palette is currently unavailable in [Embedded analytics JS](../embedding/embedded-analytics-js.md) and [interactive embedding](../embedding/interactive-embedding.md) contexts.
## Advanced search

View File

@@ -50,6 +50,8 @@ Metabase will only export the following entities:
- Collections (but personal collections don't get exported unless explicitly specified them through [export options](#customize-what-gets-exported))
- Dashboards
- Saved questions
- Transforms (including jobs)
- Documents (without comments)
- Actions
- Models
- Metrics
@@ -61,7 +63,7 @@ Metabase will only export the following entities:
- Events and timelines
- Database connection strings (only if specified through [export options](#customize-what-gets-exported))
All other entities—including users, groups, permissions, alerts, subscriptions—won't get exported.
All other entities—including users, groups, permissions, alerts, subscriptions, document comments—won't get exported.
Metabase will export its artifacts to a directory of YAML files. The export includes:
@@ -296,7 +298,7 @@ This ID refers to the collection where the question was saved. In a real export,
### Entity IDs work with embedding
Metabase supports working with [Entity IDs](#metabase-uses-entity-ids-to-identify-and-reference-metabase-items) for questions, dashboards, and collections in [Static Embedding](../embedding/static-embedding.md), [Interactive embedding](../embedding/interactive-embedding.md), and the [Embedded Analytics SDK](../embedding/sdk/introduction.md).
Metabase supports working with [Entity IDs](#metabase-uses-entity-ids-to-identify-and-reference-metabase-items) for questions, dashboards, and collections in [Static Embedding](../embedding/static-embedding.md), [Embedded analytics JS](../embedding/embedded-analytics-js.md), [Interactive embedding](../embedding/interactive-embedding.md), and the [Embedded analytics SDK](../embedding/sdk/introduction.md).
A high-level workflow for using Entity IDs when embedding Metabase in your app would look something like:

View File

@@ -1,5 +1,6 @@
---
title: Row and column security
summary: Control what data different groups can see by filtering rows and columns based on user attributes. Useful for multi-tenant analytics.
redirect_from:
- /docs/latest/enterprise-guide/data-sandboxes
- /docs/latest/permissions/data-sandboxes
@@ -46,10 +47,10 @@ Row and column security show specific data to each person based on their [user a
You can **restrict rows** by filtering a column using a [user attribute value](#choosing-user-attributes-for-row-and-column-security).
For example, you can filter the Accounts table for a group so that:
For example, you can filter the Accounts table for a group so that the user attribute filters the table:
- A person with the user attribute value "Basic" will see rows where `Plan = "Basic"` (rows where the Plan column matches the value "Basic").
- A person with the user attribute value "Premium" will see the rows where `Plan = "Premium"` (rows where the Plan column matches the value "Premium").
- "Basic" will see rows where `Plan = "Basic"` (rows where the Plan column matches the value "Basic").
- "Premium" will see the rows where `Plan = "Premium"` (rows where the Plan column matches the value "Premium").
### Custom row and column security: use a SQL question to create a custom "view" of a table
@@ -57,7 +58,7 @@ To **restrict rows _and_ columns**, you can use a SQL question to filter the tab
For example, say your original Accounts table includes the columns: `ID`, `Email`, `Plan`, and `Created At`. If you want to hide the Email column, you can create a "Restricted Accounts" SQL question with the columns: `ID`, `Plan`, and `Created At`.
You can use a question to filter tables to:
You can use a question to:
- [Display an edited column instead of hiding the column](#displaying-edited-columns).
- [Pass a user attribute to a SQL parameter](#restricting-rows-with-user-attributes-using-a-sql-variable).
@@ -69,10 +70,10 @@ You can use a question to filter tables to:
Row security displays a filtered table, in place of an original table, to a specific group. How Metabase filters that table depends on the value in each person's user attribute.
For example, you can set up a row-level security so that:
For example, you can set up a row-level security so that the user attribute `plan` shows different rows for different values:
- Someone with the user attribute with key of "plan" and a value of "Basic" will see a version of the Accounts table with a filter for `Plan = "Basic"` (that is, only the rows where the Plan column matches the value "Basic").
- Someone with a "plan" user attribute set to "Premium" will see a different version of the Accounts table with the filter `Plan = "Premium"` applied.
- "Basic" will see a version of the Accounts table with a filter for `Plan = "Basic"` (that is, only the rows where the Plan column matches the value "Basic").
- "Premium" will see a different version of the Accounts table with the filter `Plan = "Premium"` applied.
## Choosing user attributes for row and column security
@@ -179,7 +180,7 @@ In steps 9-10 of the [row restriction setup](#restricting-rows-with-user-attribu
WHERE plan = "Basic"
```
Note that the parameters must be required for SQL questions used to create a custom views. E.g., you cannot use an optional parameter; the following won't work:
Note that the parameters must be required for SQL questions used to create custom views. For example, you can't use an optional parameter; the following won't work:
```sql
{%raw%}
@@ -189,6 +190,42 @@ Note that the parameters must be required for SQL questions used to create a cus
Learn more about [SQL parameters](../questions/native-editor/sql-parameters.md)
### Advanced row-level security: filtering tables for people that have multiple IDs
For example, say have a table like this:
| User_ID | Value |
|---------|-------|
| 1 | 10 |
| 1 | 50 |
| 2 | 5 |
| 2 | 50 |
| 3 | 5 |
| 3 | 5 |
If you want to give someone access to multiple user IDs (e.g., the person should see rows for both `User_ID` 1 and 2.), you can set up a user attribute, like `user_id` that can handle comma-separated values like "1,2".
1. Create a SQL question that parses the comma-separated string and filters the table:
```sql
{%raw%}
SELECT *
FROM users_with_values
WHERE user_id = ANY(STRING_TO_ARRAY(REGEXP_REPLACE(TRIM({{user_id}}), '\\s*,\\s*', ','), ','))
{% endraw %}
```
This query:
- Trims whitespace from the user attribute value
- Replaces any spaces around commas with just commas
- Converts the comma-separated string to an array
- Filters rows where the user_id matches any value in the array
The `STRING_TO_ARRAY()` and `REGEXP_REPLACE()` functions are PostgreSQL-specific. To see which functions your database supports, see your database's documentation.
3. Set up the row and column security using this SQL question. See [setting up column security](#setting-up-column-security).
## Preventing row and column security permissions conflicts
Some Metabase permissions can conflict with row and column security to give more permissive or more restrictive data access than you intended.
@@ -199,7 +236,7 @@ The Email column may get exposed to someone if:
- The person belongs to [multiple row and column security policies](#multiple-row-and-column-security-permissions).
- Someone else in a non-secured group shares the Email column from:
- A [SQL question](../questions/native-editor/writing-sql.md).
- A [SQL question](../questions/native-editor/writing-sql.md)
- A [public link](#public-sharing)
- An [alert, or dashboard subscription](../permissions/notifications.md)
@@ -243,12 +280,13 @@ To prevent this from happening, you'll have to [disable public sharing](../embed
Metabase can only create row and column security using the group membership or user attributes of people who are logged in. Since public links don't require logins, Metabase won't have enough info to apply permissions.
## Limitations
## Limitations of row and column security
Row and column security is limited to questions built with the [query builder](../questions/query-builder/editor.md).
Some things to keep in mind when using row and column security.
### Groups with native query permissions (access to the SQL editor) can bypass row and column security
Row and column security is limited to the [query builder](../questions/query-builder/editor.md).
You can't set up [native query persmissons](./data.md#create-queries-permissions) for groups with row and column security.
To enforce row-level permissions with the native query editor, check out [impersonation](./impersonation.md).
@@ -263,6 +301,22 @@ Since Metabase can't parse SQL queries, the results of SQL questions will always
MongoDB only supports [row-level security](#row-level-security-filter-by-a-column-in-the-table). Row and column security permissions are unavailable for Apache Druid.
### Advanced data types require a workaround
If you're trying to set up row security on a column with an advanced data type (like enums or arrays), you'll need to convert that data to a basic type first. Options include:
#### Option 1: Use SQL to cast the advanced data type to a basic SQL type
Create a SQL question that casts the advanced data type column to a basic data type, then use that question for your row and column security setup.
#### Option 2: Create a database view
If you can't use SQL casting in Metabase, create a view in your database that converts the advanced data type to a basic type, then set up row and column security on that view instead of the original table. You'll also need to block the original table.
#### Option 3: Use transforms
Use a [transform](../data-modeling/transforms.md) to create a table that casts the advanced data type to a basic type. Then set up row and column security on the transformed table instead. You'll also need to block the original table.
## Further reading
- [Row and column security examples](./row-and-column-security-examples.md)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -21,7 +21,7 @@ When you select the **Map** visualization setting, Metabase will automatically t
## Pin map
Pin maps display pins for individual data points on the map. They work best for displaying unaggregated geographical data.
Pin maps display markers or tiles for individual data points on the map. They work best for displaying small amounts of unaggregated geographical data.
### Pin map data shape
@@ -30,7 +30,7 @@ To build a pin map, you need a query that returns a result that has latitude and
![Pin map data shape](../images/pin-data-shape.png)
![Pin map with a tooltip](../images/pin-map-with-tooltip.png)
Note that the tooltips will only be displayed when the map is sufficiently zoomed in using [**Draw box to filter**](#draw-box-to-filter).
Note that the tooltips will only be displayed when using the [marker pin type](#choose-pin-type).
### Create a pin map
@@ -42,9 +42,22 @@ To create a pin map:
Otherwise, click on the **Gear** icon to go to visualization settings, choose **Map type: Pin map**, and pick columns that contain latitude and longitude coordinates.
### Pin maps display 2000 pins by default
### Choose pin type
By default, pin maps will display 2000 pins even if there are more rows in your query results (this limit is the same for every chart displaying unaggregated data in Metabase). You can use the environment variable [`MB_UNAGGREGATED_QUERY_ROW_LIMIT`](../../configuring-metabase/environment-variables.md#mb_unaggregated_query_row_limit) to increase the number of data points rendered on charts based on unaggregated queries, but keep in mind that this setting will affect all charts—not just the pin maps—and it might significantly slow down your Metabase and your browser.
Metabase can display individual data points on a map as markers or tiles (small squares).
![Pin types](../images/pin-type.png)
Maps default to markers for results fewer then 1,000 rows, and to tiles for results with 1,000 rows or more.
To change the pin type:
1. While viewing the map, click the **gear** icon in the bottom left.
2. In the visualization settings, choose the pin type in the **Pin type** dropdown.
- **Markers**. Maps display up to 1,000 markers. If you choose the marker pin type for results with more than 1,000 rows, Metabase will still only display 1,000 markers. Maps default to markers for results fewer then 1,000 rows.
- **Tiles**. Maps display up to 2,000 tiles. Maps default to tiles for results greater than 1,000 rows. Tooltips will not be displayed for tiles.
By default, maps can't display more than 2,000 individual unaggregated data points, regardless of the pin type. This limit is the same for every chart displaying unaggregated data in Metabase. To increase the number of data points rendered on charts based on unaggregated queries, you can use the environment variable [`MB_UNAGGREGATED_QUERY_ROW_LIMIT`](../../configuring-metabase/environment-variables.md#mb_unaggregated_query_row_limit). This setting will affect data points on _all_ charts—not just the pin maps—so increasing the limit too much could significantly slow down your Metabase and your browswer.
If you need to display a large number of geographical data points, consider using a grid map to display the distribution instead.

View File

@@ -23,8 +23,8 @@ if (!BASE_ENV.PREMIUM_EMBEDDING_TOKEN) {
}
const BASE_SETUP_CONFIG = {
"docker-up-command": "yarn docker:up",
"docker-down-command": "yarn docker:down",
"docker-up-command": "yarn docker:local-dist:up",
"docker-down-command": "yarn docker:rm",
"docker-env-example-path": ".env.docker.example",
"docker-env-path": ".env.docker",
defaultBranch: BRANCH_NAME,

View File

@@ -3,7 +3,7 @@ import * as path from "path";
import { E2E_TMP_FOLDER_PATH } from "../constants/e2e-tmp-folder-path";
const LOCAL_DIST_PATH = "./local-dist";
const METABASE_FOLDER_PATH = "./metabase";
// See `docs/developers-guide/e2e-tests.md` for more info
export function copyShoppyMetabaseAppDBDump(rootPath: string) {
@@ -12,11 +12,11 @@ export function copyShoppyMetabaseAppDBDump(rootPath: string) {
"db_dumps/shoppy_metabase_app_db_dump.sql",
);
// The dump must be copied to the Shoppy's `local-dist` directory.
// The dump must be copied to the Shoppy's `metabase` directory.
// After that it will be used by the Shoppy's Docker container when it is built and started.
const destinationPath = path.join(
rootPath,
LOCAL_DIST_PATH,
METABASE_FOLDER_PATH,
"metabase_dump.sql",
);

View File

@@ -24,6 +24,7 @@ export interface BaseEmbedTestPageOptions {
instanceUrl?: string;
apiKey?: string;
useExistingUserSession?: boolean;
fetchRequestToken?: () => Promise<{ jwt: string }>;
theme?: MetabaseTheme;
preferredAuthMethod?: "jwt" | "saml";
locale?: string;
@@ -39,7 +40,7 @@ export interface BaseEmbedTestPageOptions {
afterEmbed?: string;
};
onVisitPage?(): void;
onVisitPage?(win: Cypress.AUTWindow): void;
}
export interface MetabaseElement {
@@ -267,14 +268,7 @@ export const getNewEmbedConfigurationScript = ({
useExistingUserSession,
preferredAuthMethod,
locale,
}: {
instanceUrl?: string;
theme?: MetabaseTheme;
apiKey?: string;
useExistingUserSession?: boolean;
preferredAuthMethod?: "jwt" | "saml";
locale?: string;
} = {}) => {
}: BaseEmbedTestPageOptions["metabaseConfig"] = {}) => {
const config = {
instanceUrl,
apiKey,

View File

@@ -0,0 +1,37 @@
import type {
ListTransformRunsResponse,
TransformId,
TransformRun,
} from "metabase-types/api";
export function visitTransform(transformId: TransformId) {
cy.visit(`/admin/transforms/${transformId}`);
}
const WAIT_TIMEOUT = 10000;
const WAIT_INTERVAL = 100;
export function waitForTransformRuns(
filter: (runs: TransformRun[]) => boolean,
timeout = WAIT_TIMEOUT,
): Cypress.Chainable {
return cy
.request<ListTransformRunsResponse>("GET", "/api/ee/transform/run")
.then((response) => {
if (filter(response.body.data)) {
return cy.wrap(response);
} else if (timeout > 0) {
cy.wait(WAIT_INTERVAL);
return waitForTransformRuns(filter, timeout - WAIT_INTERVAL);
} else {
throw new Error("Run retry timeout");
}
});
}
export function waitForSucceededTransformRuns() {
waitForTransformRuns(
(runs) =>
runs.length > 0 && runs.every((run) => run.status === "succeeded"),
);
}

View File

@@ -11,7 +11,29 @@ import {
} from "e2e/support/helpers";
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
export const mockAuthProviderAndJwtSignIn = (user = USERS.admin) => {
export const getSignedJwtForUser = async ({
user = USERS.admin,
expiredInSeconds = 60 * 10, // 10 minutes expiration,
}: {
user?: { email: string; first_name: string; last_name: string };
expiredInSeconds?: number;
}) => {
const secret = new TextEncoder().encode(JWT_SHARED_SECRET);
return new jose.SignJWT({
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
exp: Math.round(Date.now() / 1000) + expiredInSeconds,
})
.setProtectedHeader({ alg: "HS256" })
.sign(secret);
};
export const mockAuthProviderAndJwtSignIn = (
user = USERS.admin,
{ jwt }: { jwt?: string } = {},
) => {
cy.intercept("GET", `${AUTH_PROVIDER_URL}**`, async (req) => {
try {
const url = new URL(req.url);
@@ -26,15 +48,7 @@ export const mockAuthProviderAndJwtSignIn = (user = USERS.admin) => {
return;
}
const secret = new TextEncoder().encode(JWT_SHARED_SECRET);
const jwt = await new jose.SignJWT({
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
exp: Math.round(Date.now() / 1000) + 60 * 10, // 10 minutes expiration
})
.setProtectedHeader({ alg: "HS256" })
.sign(secret);
jwt = jwt || (await getSignedJwtForUser({ user }));
req.reply({
statusCode: 200,

View File

@@ -47,6 +47,7 @@ export * from "./e2e-slack-helpers";
export * from "./e2e-snowplow-helpers";
export * from "./e2e-table-metadata-helpers";
export * from "./e2e-token-helpers";
export * from "./e2e-transform-helpers";
export * from "./e2e-ui-elements-helpers";
export * from "./e2e-ui-elements-overflow-helpers";
export * from "./e2e-upload-helpers";

View File

@@ -1,6 +1,7 @@
import {
EditableDashboard,
InteractiveDashboard,
InteractiveQuestion,
} from "@metabase/embedding-sdk-react";
const { H } = cy;
@@ -26,6 +27,30 @@ describe("scenarios > embedding-sdk > dashboard-click-behavior", () => {
semantic_type: "type/URL",
});
// Create a question with custom column that returns a url as a string
H.createQuestion({
name: "Orders with URL Expression",
query: {
"source-table": ORDERS_ID,
expressions: {
// NOTE: this is a question, so it won't have semantic_type set as url, we'll need a model for that
url_column: [
"concat",
"https://example.org/",
["field", ORDERS.ID, { "base-type": "type/BigInteger" }],
],
},
fields: [
["field", ORDERS.ID, { "base-type": "type/BigInteger" }],
["expression", "url_column", { "base-type": "type/Text" }],
["field", ORDERS.PRODUCT_ID, null],
],
limit: 5,
},
}).then(({ body: question }) => {
cy.wrap(question.id).as("questionId");
});
H.createDashboardWithQuestions({
dashboardName: "Orders in a dashboard",
questions: [
@@ -60,7 +85,7 @@ describe("scenarios > embedding-sdk > dashboard-click-behavior", () => {
],
cards: [
{
size_x: 12,
size_x: 24,
col: 0,
visualization_settings: {
column_settings: {
@@ -77,7 +102,7 @@ describe("scenarios > embedding-sdk > dashboard-click-behavior", () => {
},
{
size_x: 12,
col: 12,
col: 0,
visualization_settings: {
click_behavior: {
type: "link",
@@ -193,4 +218,123 @@ describe("scenarios > embedding-sdk > dashboard-click-behavior", () => {
cy.findByTestId("visualization-root").should("be.visible");
});
});
it("columns that return a string url should be rendered as a link", () => {
cy.get<string>("@questionId").then((questionId) => {
mountSdkContent(<InteractiveQuestion questionId={questionId} />);
});
cy.intercept("GET", "/api/card/*").as("getCard");
cy.wait("@getCard");
cy.findByRole("link", { name: "https://example.org/448" })
.should("have.length", 1)
.should("have.attr", "href", "https://example.org/448");
});
it("columns that have 'type/URL' semantic type should be rendered as a link", () => {
cy.signIn("admin");
cy.get<string>("@questionId").then((questionId) => {
// Convert question to model
cy.request("GET", `/api/card/${questionId}`)
.then((response) => {
const questionData = response.body;
return cy.request("PUT", `/api/card/${questionId}`, {
...questionData,
type: "model",
});
})
.then(() => {
// re-fetch it as model
return cy.request("GET", `/api/card/${questionId}`);
})
.then((modelResponse) => {
// Update model column semantic type for url_column to type/URL
const modelData = modelResponse.body;
const updatedMetadata = modelData.result_metadata.map((col: any) => {
if (col.name === "url_column") {
return { ...col, semantic_type: "type/URL" };
}
return col;
});
return cy.request("PUT", `/api/card/${questionId}`, {
...modelData,
result_metadata: updatedMetadata,
});
})
.then(() => {
// Create dashboard with the model
H.createDashboardWithQuestions({
dashboardName: "URL Dashboard",
questions: [
{
name: "Orders with URLs",
query: {
"source-table": `card__${questionId}`,
limit: 5,
},
},
],
}).then(({ dashboard }) => {
mountSdkContent(
<InteractiveDashboard dashboardId={dashboard.id} />,
);
getSdkRoot().within(() => {
H.getDashboardCard(0)
.findByRole("link", {
name: "https://example.org/448",
})
.should("have.attr", "href", "https://example.org/448");
});
});
});
});
});
it("columns that have 'Display as Link' should be rendered as a link and use the custom rendering", () => {
cy.signIn("admin");
cy.intercept("GET", "/api/card/*").as("getCard");
cy.get<string>("@questionId").then((questionId) => {
H.createDashboardWithQuestions({
dashboardName: "URL Dashboard",
questions: [
{
name: "Orders with URLs",
query: {
"source-table": `card__${questionId}`,
limit: 5,
},
},
],
cards: [
{
visualization_settings: {
column_settings: {
[JSON.stringify(["name", "ID"])]: {
view_as: "link",
link_text: "Link to {{ID}}",
link_url: "https://example.org/{{ID}}",
},
},
},
},
],
}).then(({ dashboard }) => {
mountSdkContent(<EditableDashboard dashboardId={dashboard.id} />);
getSdkRoot().within(() => {
H.getDashboardCard(0)
.findByRole("link", {
name: "Link to 448",
})
.should("have.attr", "href", "https://example.org/448");
});
});
});
});
});

View File

@@ -0,0 +1,50 @@
const { H } = cy;
import { InteractiveQuestion } from "@metabase/embedding-sdk-react";
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import { mountSdkContent } from "e2e/support/helpers/embedding-sdk-component-testing/component-embedding-sdk-helpers";
import { signInAsAdminAndEnableEmbeddingSdk } from "e2e/support/helpers/embedding-sdk-testing";
import { mockAuthProviderAndJwtSignIn } from "e2e/support/helpers/embedding-sdk-testing/embedding-sdk-helpers";
describe("scenarios > embedding-sdk > interactive-question", () => {
beforeEach(() => {
signInAsAdminAndEnableEmbeddingSdk();
H.createNativeQuestion(
{
name: "SQL Orders",
native: { query: "select * from ORDERS limit 5" },
},
{
wrapId: true,
idAlias: "sqlQuestionId",
},
);
cy.signOut();
mockAuthProviderAndJwtSignIn();
});
it("should be able to drill SQL question (EMB-273)", () => {
cy.get("@sqlQuestionId").then((sqlQuestionId) => {
mountSdkContent(
<InteractiveQuestion questionId={sqlQuestionId} withParameters />,
);
});
getSdkRoot().within(() => {
cy.findByText("SQL Orders").should("be.visible");
H.assertTableRowsCount(5);
// Drill down to "See these Orders"
cy.get("[data-dataset-index=0] > [data-column-id='PRODUCT_ID']")
.should("have.text", "14")
.click();
H.popover().within(() => {
cy.findByText("Filter by this value").should("be.visible");
cy.button(">").click();
});
H.assertTableRowsCount(4);
});
});
});

View File

@@ -171,4 +171,101 @@ describe("scenarios > embedding-sdk > metabot-question", () => {
});
});
});
it("should show suggestion buttons when no messages exist", () => {
setup(metabotResponse);
mockSuggestedPrompts();
mountSdkContent(<MetabotQuestion />);
getSdkRoot().within(() => {
// Verify suggestion buttons are visible
cy.findAllByTestId("metabot-suggestion-button", { timeout: 10_000 })
.should("have.length", 3)
.should("be.visible");
// Verify suggestion texts are shown in order
cy.findAllByTestId("metabot-suggestion-button")
.eq(0)
.should("contain.text", "Show me total sales by product");
cy.findAllByTestId("metabot-suggestion-button")
.eq(1)
.should("contain.text", "What are the top performing products?");
cy.findAllByTestId("metabot-suggestion-button")
.eq(2)
.should("contain.text", "How many orders were placed last month?");
// Clicking on a suggestion should remove the suggestion buttons and add the user's message
cy.findByText("Show me total sales by product").click();
cy.findAllByTestId("metabot-suggestion-button").should("not.exist");
cy.findAllByTestId("metabot-chat-message").should("have.length", 2);
cy.findAllByTestId("metabot-chat-message")
.first()
.should("contain.text", "Show me total sales by product");
});
});
it("should switch to stacked layout on small viewport", () => {
setup(metabotResponse);
mountSdkContent(<MetabotQuestion />);
getSdkRoot().within(() => {
// On desktop, use the sidebar layout
cy.findByTestId("metabot-question-container").should(
"have.attr",
"data-layout",
"sidebar",
);
// On mobile, use the stacked layout
cy.viewport(400, 600);
cy.findByTestId("metabot-overflow-button").should("be.visible");
cy.findByTestId("metabot-question-container").should(
"have.attr",
"data-layout",
"stacked",
);
// On desktop, use the sidebar layout
cy.viewport(1200, 800);
cy.findByTestId("metabot-overflow-button").should("not.be.visible");
cy.findByTestId("metabot-question-container").should(
"have.attr",
"data-layout",
"sidebar",
);
});
});
});
const mockSuggestedPrompts = () => {
cy.intercept(
"GET",
"/api/ee/metabot-v3/metabot/2/prompt-suggestions?limit=3&sample=true",
{
statusCode: 200,
body: {
prompts: [
{
id: 1,
prompt: "Show me total sales by product",
},
{
id: 2,
prompt: "What are the top performing products?",
},
{
id: 3,
prompt: "How many orders were placed last month?",
},
],
limit: 3,
offset: 0,
total: 3,
},
},
).as("suggestedPrompts");
};

View File

@@ -1,7 +1,20 @@
import { CollectionBrowser } from "@metabase/embedding-sdk-react";
import {
CollectionBrowser,
InteractiveQuestion,
MetabaseProvider,
} from "@metabase/embedding-sdk-react";
import { mountSdkContent } from "e2e/support/helpers/embedding-sdk-component-testing/component-embedding-sdk-helpers";
import { signInAsAdminAndEnableEmbeddingSdk } from "e2e/support/helpers/embedding-sdk-testing";
import { USERS } from "e2e/support/cypress_data";
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import {
DEFAULT_SDK_AUTH_PROVIDER_CONFIG,
mountSdkContent,
} from "e2e/support/helpers/embedding-sdk-component-testing/component-embedding-sdk-helpers";
import {
getSignedJwtForUser,
signInAsAdminAndEnableEmbeddingSdk,
} from "e2e/support/helpers/embedding-sdk-testing";
import { mockAuthProviderAndJwtSignIn } from "e2e/support/helpers/embedding-sdk-testing/embedding-sdk-helpers";
describe("scenarios > embedding-sdk > requests", () => {
@@ -23,5 +36,39 @@ describe("scenarios > embedding-sdk > requests", () => {
expect(maxAgeHeader).to.equal("60");
});
});
it("properly performs session token refresh request when multiple data requests are triggered at the same time", () => {
const expiredInSeconds = 60;
cy.clock(Date.now());
cy.then(() => getSignedJwtForUser({ expiredInSeconds })).then((jwt) => {
mockAuthProviderAndJwtSignIn(USERS.admin, { jwt });
});
cy.mount(
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
</MetabaseProvider>,
);
getSdkRoot().within(() => {
cy.findByText("Orders").should("exist");
cy.findByText("Group").click();
cy.findByRole("dialog").should("be.visible");
cy.tick(1000 * (expiredInSeconds + 5));
cy.findByRole("dialog").contains(/^ID$/).click();
cy.findByRole("dialog").findByTestId("badge-remove-button").click();
cy.wait("@jwtProvider");
cy.intercept("POST", "/api/dataset").as("dataset");
// We ensure that both `dataset` requests are made after the token refresh request
cy.get("@dataset.all").should("have.length", 2);
});
});
});
});

View File

@@ -3,6 +3,7 @@ import { useState } from "react";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
type NativeQuestionDetails,
createNativeQuestion,
tableInteractiveBody,
} from "e2e/support/helpers";
@@ -15,122 +16,281 @@ import { signInAsAdminAndEnableEmbeddingSdk } from "e2e/support/helpers/embeddin
import { mockAuthProviderAndJwtSignIn } from "e2e/support/helpers/embedding-sdk-testing/embedding-sdk-helpers";
import { Box, Button } from "metabase/ui";
import type { DatasetColumn } from "metabase-types/api";
import { createMockParameter } from "metabase-types/api/mocks";
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
describe("scenarios > embedding-sdk > interactive-question > native", () => {
beforeEach(() => {
signInAsAdminAndEnableEmbeddingSdk();
const setup = ({ question }: { question: NativeQuestionDetails }) => {
signInAsAdminAndEnableEmbeddingSdk();
createNativeQuestion(
{
native: {
query: "SELECT * FROM orders WHERE {{ID}}",
"template-tags": {
ID: {
id: "6b8b10ef-0104-1047-1e1b-2492d5954322",
name: "ID",
"display-name": "ID",
type: "dimension",
dimension: ["field", ORDERS.ID, null],
"widget-type": "category",
default: null,
createNativeQuestion(question, { wrapId: true });
cy.signOut();
mockAuthProviderAndJwtSignIn();
};
const ensureParameterColumnValue = ({
columnName,
columnValue,
}: {
columnName: string;
columnValue: string;
}) => {
tableInteractiveBody().within(() => {
cy.get(`[data-column-id="${columnName}"]`).each((cell) => {
cy.wrap(cell).should("have.text", columnValue);
});
});
};
describe("scenarios > embedding-sdk > interactive-question > native", () => {
describe("common", () => {
beforeEach(() => {
setup({
question: {
native: {
query: "SELECT * FROM orders WHERE {{ID}}",
"template-tags": {
ID: {
id: "6b8b10ef-0104-1047-1e1b-2492d5954322",
name: "ID",
"display-name": "ID",
type: "dimension",
dimension: ["field", ORDERS.ID, null],
"widget-type": "category",
default: null,
},
},
},
},
},
{ wrapId: true },
);
cy.signOut();
mockAuthProviderAndJwtSignIn();
});
it("supports passing sql parameters to native questions", () => {
mountInteractiveQuestion({ initialSqlParameters: { ID: ORDERS_ID } });
cy.wait("@cardQuery").then(({ response }) => {
const { body } = response ?? {};
const rows = tableInteractiveBody().findAllByRole("row");
// There should be one row in the table
rows.should("have.length", 1);
const idColumnIndex = body.data.cols.findIndex(
(column: DatasetColumn) => column.name === "ID",
);
// The first row should have the same ID column value as the initial SQL parameters
// eslint-disable-next-line no-unsafe-element-filtering
rows
.findAllByTestId("cell-data")
.eq(idColumnIndex)
.should("have.text", String(ORDERS_ID));
});
});
it("should not crash when switching from new question to native question (metabase#60160)", () => {
const TestComponent = ({
nativeQuestionId,
}: {
nativeQuestionId: string | number;
}) => {
const [questionId, setQuestionId] = useState<string | number>("new");
return (
<Box>
<InteractiveQuestion questionId={questionId} />
<Button onClick={() => setQuestionId(nativeQuestionId)}>
use native question
</Button>
</Box>
);
};
cy.get<string>("@questionId").then((nativeQuestionId) => {
mountSdkContent(<TestComponent nativeQuestionId={nativeQuestionId} />);
getSdkRoot().within(() => {
cy.findByText("Pick your starting data").should("be.visible");
});
});
cy.findByText("use native question").click();
it("supports passing sql parameters to native questions", () => {
mountInteractiveQuestion({ initialSqlParameters: { ID: ORDERS_ID } });
cy.wait("@cardQuery").then(({ response }) => {
const { body } = response ?? {};
const rows = tableInteractiveBody().findAllByRole("row");
// There should be one row in the table
rows.should("have.length", 1);
const idColumnIndex = body.data.cols.findIndex(
(column: DatasetColumn) => column.name === "ID",
);
// The first row should have the same ID column value as the initial SQL parameters
// eslint-disable-next-line no-unsafe-element-filtering
rows
.findAllByTestId("cell-data")
.eq(idColumnIndex)
.should("have.text", String(ORDERS_ID));
});
});
it("should not crash when switching from new question to native question (metabase#60160)", () => {
const TestComponent = ({
nativeQuestionId,
}: {
nativeQuestionId: string | number;
}) => {
const [questionId, setQuestionId] = useState<string | number>("new");
return (
<Box>
<InteractiveQuestion questionId={questionId} />
<Button onClick={() => setQuestionId(nativeQuestionId)}>
use native question
</Button>
</Box>
);
};
cy.get<string>("@questionId").then((nativeQuestionId) => {
mountSdkContent(<TestComponent nativeQuestionId={nativeQuestionId} />);
getSdkRoot().within(() => {
cy.findByText("Pick your starting data").should("be.visible");
});
cy.findByText("use native question").click();
cy.log("should show native question table data");
getSdkRoot().within(() => {
cy.findByText("Pick your starting data").should("not.exist");
cy.findByText("test question").should("be.visible");
cy.findByText("110.93").should("be.visible");
});
cy.log("should not crash due to preview-query error");
cy.on("uncaught:exception", (error) => {
expect(
error.message.includes(
"preview-query cannot be called on native queries",
),
).to.be.false;
});
});
});
it("should not show filter/summarize/breakout buttons for native questions", () => {
mountInteractiveQuestion({});
cy.log("should show native question table data");
getSdkRoot().within(() => {
cy.findByText("Pick your starting data").should("not.exist");
cy.log("should show native question data");
cy.findByText("test question").should("be.visible");
cy.findByText("110.93").should("be.visible");
});
cy.log("should not crash due to preview-query error");
cy.on("uncaught:exception", (error) => {
expect(
error.message.includes(
"preview-query cannot be called on native queries",
),
).to.be.false;
cy.log("should not show Filter button");
cy.findByText("Filter").should("not.exist");
cy.log("should not show Summarize button");
cy.findByText("Summarize").should("not.exist");
cy.log("should not show Group button");
cy.findByText("Group").should("not.exist");
});
});
});
it("should not show filter/summarize/breakout buttons for native questions", () => {
mountInteractiveQuestion({});
describe("editable parameters for a native question", () => {
beforeEach(() => {
setup({
question: {
native: {
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",
},
},
},
parameters: [
createMockParameter({
id: "1",
slug: "State",
}),
createMockParameter({
id: "2",
slug: "City",
}),
createMockParameter({
id: "3",
slug: "Source",
}),
],
},
});
});
getSdkRoot().within(() => {
cy.log("should show native question data");
cy.findByText("test question").should("be.visible");
it("does not show editable sql parameters controls for the default view", () => {
mountInteractiveQuestion({
initialSqlParameters: { State: "NY" },
hiddenParameters: ["Source"],
});
cy.log("should not show Filter button");
cy.findByText("Filter").should("not.exist");
cy.wait("@cardQuery");
cy.log("should not show Summarize button");
cy.findByText("Summarize").should("not.exist");
cy.findByTestId("parameter-widget").should("not.exist");
cy.findByPlaceholderText("State").should("not.exist");
cy.findByPlaceholderText("City").should("not.exist");
cy.findByPlaceholderText("Source").should("not.exist");
});
cy.log("should not show Group button");
cy.findByText("Group").should("not.exist");
describe("when `SqlParametersList` component is defined in a custom question layout", () => {
it("allows to edit sql parameters", () => {
mountInteractiveQuestion({
hiddenParameters: ["Source"],
children: (
<>
<InteractiveQuestion.Title />
<InteractiveQuestion.SqlParametersList />
<InteractiveQuestion.QuestionVisualization />
</>
),
});
cy.wait("@cardQuery");
cy.findByPlaceholderText("State").should("exist");
cy.findByPlaceholderText("City").should("exist");
cy.findByPlaceholderText("Source").should("not.exist");
getSdkRoot().within(() => {
cy.findByPlaceholderText("State").clear().type("NY{enter}");
});
ensureParameterColumnValue({
columnName: "STATE",
columnValue: "NY",
});
getSdkRoot().within(() => {
cy.findByPlaceholderText("State").clear().type("AR{enter}");
});
ensureParameterColumnValue({
columnName: "STATE",
columnValue: "AR",
});
getSdkRoot().within(() => {
cy.findByPlaceholderText("City").type("El Paso{enter}");
});
ensureParameterColumnValue({
columnName: "CITY",
columnValue: "El Paso",
});
});
it("initializes sql parameters controls with initial values and keeps an initial value when changing other parameters", () => {
mountInteractiveQuestion({
initialSqlParameters: { State: "AR" },
hiddenParameters: ["Source"],
children: (
<>
<InteractiveQuestion.Title />
<InteractiveQuestion.SqlParametersList />
<InteractiveQuestion.QuestionVisualization />
</>
),
});
cy.wait("@cardQuery");
ensureParameterColumnValue({
columnName: "STATE",
columnValue: "AR",
});
getSdkRoot().within(() => {
cy.findByPlaceholderText("City").type("El Paso{enter}");
});
ensureParameterColumnValue({
columnName: "CITY",
columnValue: "El Paso",
});
});
});
});
});

View File

@@ -1036,7 +1036,7 @@ describe("scenarios > admin > datamodel", () => {
column: "New tax",
values: ["2.07", "6.1", "2.9", "6.01", "7.03"],
});
verifyObjectDetailPreview({ index: 4, row: ["New tax", "2.07"] });
verifyObjectDetailPreview({ rowNumber: 4, row: ["New tax", "2.07"] });
cy.log("verify viz");
H.openOrdersTable();
@@ -1429,7 +1429,7 @@ describe("scenarios > admin > datamodel", () => {
column: "New tax",
values: ["2.07", "6.1", "2.9", "6.01", "7.03"],
});
verifyObjectDetailPreview({ index: 4, row: ["New tax", "2.07"] });
verifyObjectDetailPreview({ rowNumber: 4, row: ["New tax", "2.07"] });
cy.log("verify viz");
H.openOrdersTable();
@@ -1571,7 +1571,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["14", "123", "105", "94", "132"],
});
verifyObjectDetailPreview({
index: 2,
rowNumber: 2,
row: ["Remapped Product ID", "14"],
});
@@ -1669,7 +1669,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
index: 4,
rowNumber: 4,
row: ["Rating", "December 31, 1969, 4:00 PM"],
});
@@ -1718,7 +1718,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
index: 4,
rowNumber: 4,
row: ["Rating", "December 31, 1969, 4:00 PM"],
});
@@ -1989,8 +1989,8 @@ describe("scenarios > admin > datamodel", () => {
values: ["2.07", "6.10", "2.90", "6.01", "7.03"],
});
verifyObjectDetailPreview({
index: 4,
row: ["Tax ($)", "$2.07"],
rowNumber: 4,
row: ["Tax ($)", "2.07"],
});
cy.log("change currency");
@@ -2009,8 +2009,8 @@ describe("scenarios > admin > datamodel", () => {
values: ["2.07", "6.10", "2.90", "6.01", "7.03"],
});
verifyObjectDetailPreview({
index: 4,
row: ["Tax (CA$)", "CA$2.07"],
rowNumber: 4,
row: ["Tax (CA$)", "2.07"],
});
cy.log("verify viz");
@@ -2175,7 +2175,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["2.07", "6.1", "2.9", "6.01", "7.03"],
});
verifyObjectDetailPreview({
index: 4,
rowNumber: 4,
row: ["Tax", "2.07"],
});
@@ -2293,7 +2293,7 @@ describe("scenarios > admin > datamodel", () => {
.should("be.visible");
cy.get("@dataset.all").should("have.length", 0);
verifyObjectDetailPreview({
index: 4,
rowNumber: 4,
row: ["Tax", "2.07"],
});
@@ -2509,7 +2509,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["14", "123", "105", "94", "132"],
});
verifyObjectDetailPreview({
index: 2,
rowNumber: 2,
row: ["Product ID", "14"],
});
@@ -2529,7 +2529,7 @@ describe("scenarios > admin > datamodel", () => {
cy.log("verify preview");
verifyObjectDetailPreview({
index: 2,
rowNumber: 2,
row: ["Product ID", "Awesome Concrete Shoes"],
});
verifyTablePreview({
@@ -2670,7 +2670,7 @@ describe("scenarios > admin > datamodel", () => {
cy.log("verify preview");
FieldSection.getPreviewButton().click();
verifyObjectDetailPreview({
index: 2,
rowNumber: 2,
row: ["Product ID", "Awesome Concrete Shoes"],
});
verifyTablePreview({
@@ -2755,7 +2755,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
index: 3,
rowNumber: 3,
row: ["Rating", "Perfecto"],
});
@@ -2846,6 +2846,10 @@ describe("scenarios > admin > datamodel", () => {
'You can only use custom mapping for numerical fields with filtering set to "A list of all values"',
);
cy.log("close popover by clicking on element inside panel");
FieldSection.get().findByText("Field settings").click();
cy.log("open popover");
FieldSection.getFilteringInput().click();
H.popover().findByText("A list of all values").click();
cy.wait("@updateField");
@@ -2898,7 +2902,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
index: 1,
rowNumber: 1,
row: ["User ID", "2023-10-07T01:34:35.462-07:00"],
});
@@ -2952,7 +2956,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["10", "10"],
});
verifyObjectDetailPreview({
index: 1,
rowNumber: 1,
row: ["Json → A", "10"],
});
@@ -3107,7 +3111,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["200%", "300%", "200%", "600%", "500%"],
});
verifyObjectDetailPreview({
index: 8,
rowNumber: 8,
row: ["Quantity", "200%"],
});
});
@@ -3177,7 +3181,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["about 2", "about 3", "about 2", "about 6", "about 5"],
});
verifyObjectDetailPreview({
index: 8,
rowNumber: 8,
row: ["Quantity", "about 2"],
});
@@ -3865,31 +3869,28 @@ function verifyTablePreview({
}
function verifyObjectDetailPreview({
index,
rowNumber,
row,
}: {
index: number;
rowNumber: number;
row: [string, string];
}) {
const [label, value] = row;
const labelIndex = index * 2;
const valueIndex = labelIndex + 1;
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
cy.findAllByTestId("object-details-table-cell").should((elements) => {
const index = [...elements].findIndex(
(element) => element.textContent?.trim() === label,
);
expect(index).to.eq(labelIndex);
});
cy.findAllByTestId("column-name").then(($els) => {
const foundRowIndex = $els
.toArray()
.findIndex((el) => el.textContent?.trim() === label);
cy.findAllByTestId("object-details-table-cell").should((elements) => {
const index = [...elements].findIndex(
(element) => element.textContent?.trim() === value,
);
expect(index).to.eq(valueIndex);
expect(rowNumber).to.eq(foundRowIndex);
cy.findAllByTestId("value")
.should("have.length.gte", foundRowIndex)
.eq(foundRowIndex)
.should("contain", value);
});
}

View File

@@ -6,7 +6,6 @@ import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import type {
CardType,
ListTransformRunsResponse,
PythonTransformTableAliases,
TransformTagId,
} from "metabase-types/api";
@@ -637,6 +636,114 @@ LIMIT
});
});
describe("filtering", () => {
it("should be able to filter transforms", () => {
createMbqlTransform({ visitTransform: true });
runJobAndWaitForSuccess();
createSqlTransform({
sourceQuery: `SELECT * FROM "${TARGET_SCHEMA}"."${SOURCE_TABLE}"`,
targetTable: TARGET_TABLE_2,
visitTransform: true,
});
getTagsInput().click();
H.popover().findByText("hourly").click();
H.undoToast().findByText("Transform tags updated").should("be.visible");
visitTransformListPage();
function testLastRunDateFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
cy.log("last run at - add a filter");
getLastRunDateFilterWidget().click();
H.popover().within(() => {
cy.findByText("Relative date range…").click();
cy.findByText("Current").click();
cy.findByText("Week").click();
});
getLastRunDateFilterWidget().should("contain", "This week");
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("not.exist");
});
cy.log("last run at filter - remove filter");
getLastRunDateFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
}
function testLastRunStatusFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
cy.log("last run status - add a filter");
getLastRunStatusFilterWidget().click();
H.popover().within(() => {
cy.findByText("Success").click();
cy.findByText("Add filter").click();
});
getLastRunStatusFilterWidget().should("contain", "Success");
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("not.exist");
});
cy.log("last run status filter - remove filter");
getLastRunStatusFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
}
function testTagFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
cy.log("tags - add a filter");
getTagFilterWidget().click();
H.popover().within(() => {
cy.findByText("hourly").click();
cy.findByText("daily").click();
cy.button("Add filter").click();
});
getTagFilterWidget().should("contain", "2 tags");
getContentTable().within(() => {
cy.findByText("SQL transform").should("be.visible");
cy.findByText("MBQL transform").should("not.exist");
});
cy.log("last run status filter - remove filter");
getTagFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
}
testLastRunDateFilter();
testLastRunStatusFilter();
testTagFilter();
});
});
describe("name and description", () => {
it("should be able to edit the name and description after creation", () => {
createMbqlTransform({ visitTransform: true });
@@ -1116,6 +1223,7 @@ LIMIT
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: TARGET_TABLE,
schema: TARGET_SCHEMA,
},
@@ -1229,6 +1337,7 @@ LIMIT
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: TARGET_TABLE,
schema: TARGET_SCHEMA,
},
@@ -1437,6 +1546,7 @@ LIMIT
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: TARGET_TABLE,
schema: TARGET_SCHEMA,
},
@@ -1833,7 +1943,7 @@ describe("scenarios > admin > transforms > jobs", () => {
tag_ids: [tag.id],
});
});
waitForRuns();
H.waitForSucceededTransformRuns();
visitRunListPage();
getContentTable().within(() => {
cy.findAllByText("MBQL transform").should("have.length.gte", 1);
@@ -1997,13 +2107,11 @@ describe("scenarios > admin > transforms > jobs", () => {
it("should be able to filter jobs ", () => {
cy.log("run hourly job so know that was recently run");
visitJobListPage();
getContentTable().findByText("Hourly job").click();
runJobAndWaitForSuccess();
visitJobListPage();
function testLastRunFilter() {
function testLastRunDateFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
@@ -2013,14 +2121,14 @@ describe("scenarios > admin > transforms > jobs", () => {
});
cy.log("last run at - add a filter");
getLastRunAtFilterWidget().click();
getLastRunDateFilterWidget().click();
H.popover().findByText("Previous month").click();
getLastRunAtFilterWidget().should("contain", "Previous month");
getLastRunDateFilterWidget().should("contain", "Previous month");
getContentTable().should("not.exist");
cy.log("last run at filter - remove filter");
getLastRunAtFilterWidget().button("Remove filter").click();
getLastRunDateFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
cy.findByText("Daily job").should("be.visible");
@@ -2029,7 +2137,42 @@ describe("scenarios > admin > transforms > jobs", () => {
});
}
function testNextRunFilter() {
function testLastRunStatusFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
cy.findByText("Daily job").should("be.visible");
cy.findByText("Weekly job").should("be.visible");
cy.findByText("Monthly job").should("be.visible");
});
cy.log("last run status - add a filter");
getLastRunStatusFilterWidget().click();
H.popover().within(() => {
cy.findByText("In progress").click();
cy.findByText("Success").click();
cy.button("Add filter").click();
});
getLastRunStatusFilterWidget().should("contain", "2 statuses");
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
cy.findByText("Daily job").should("not.exist");
cy.findByText("Weekly job").should("not.exist");
cy.findByText("Monthly job").should("not.exist");
});
cy.log("last run at filter - remove filter");
getLastRunStatusFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
cy.findByText("Daily job").should("be.visible");
cy.findByText("Weekly job").should("be.visible");
cy.findByText("Monthly job").should("be.visible");
});
}
function testNextRunDateFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
@@ -2039,7 +2182,7 @@ describe("scenarios > admin > transforms > jobs", () => {
});
cy.log("next run - add a filter");
getNextRunFilterWidget().click();
getNextRunDateFilterWidget().click();
H.popover().within(() => {
cy.findByText("Fixed date range…").click();
cy.findByLabelText("Start date").clear().type("12/10/2024");
@@ -2047,14 +2190,14 @@ describe("scenarios > admin > transforms > jobs", () => {
cy.button("Apply").click();
});
getNextRunFilterWidget().should(
getNextRunDateFilterWidget().should(
"contain",
"December 10, 2024 - January 5, 2025",
);
getContentTable().should("not.exist");
cy.log("next run filter - remove filter");
getNextRunFilterWidget().button("Remove filter").click();
getNextRunDateFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
cy.findByText("Daily job").should("be.visible");
@@ -2127,8 +2270,9 @@ describe("scenarios > admin > transforms > jobs", () => {
});
}
testLastRunFilter();
testNextRunFilter();
testLastRunDateFilter();
testLastRunStatusFilter();
testNextRunDateFilter();
testTagFilter();
});
});
@@ -2617,11 +2761,15 @@ function getTransformFilterWidget() {
return cy.findByRole("group", { name: "Transform" });
}
function getLastRunAtFilterWidget() {
function getLastRunDateFilterWidget() {
return cy.findByRole("group", { name: "Last run at" });
}
function getNextRunFilterWidget() {
function getLastRunStatusFilterWidget() {
return cy.findByRole("group", { name: "Last run status" });
}
function getNextRunDateFilterWidget() {
return cy.findByRole("group", { name: "Next run at" });
}
@@ -2638,11 +2786,11 @@ function getRunMethodFilterWidget() {
}
function getStartAtFilterWidget() {
return cy.findByRole("group", { name: "Start at" });
return cy.findByRole("group", { name: "Started at" });
}
function getEndAtFilterWidget() {
return cy.findByRole("group", { name: "End at" });
return cy.findByRole("group", { name: "Ended at" });
}
function visitTransformListPage() {
@@ -2712,6 +2860,7 @@ function createMbqlTransform({
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: targetTable,
schema: targetSchema,
},
@@ -2750,6 +2899,7 @@ function createSqlTransform({
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: targetTable,
schema: targetSchema,
},
@@ -2810,24 +2960,6 @@ function visitTableQuestion({
);
}
const WAIT_TIMEOUT = 10000;
const WAIT_INTERVAL = 100;
function waitForRuns(timeout = WAIT_TIMEOUT): Cypress.Chainable {
return cy
.request<ListTransformRunsResponse>("GET", "/api/ee/transform/run")
.then((response) => {
if (response.body.data.length > 0) {
return cy.wrap(response);
} else if (timeout > 0) {
cy.wait(WAIT_INTERVAL);
return waitForRuns(timeout - WAIT_INTERVAL);
} else {
throw new Error("Run retry timeout");
}
});
}
function assertTableDoesNotExistError({
targetTable = TARGET_TABLE,
targetSchema = TARGET_SCHEMA,

View File

@@ -99,14 +99,6 @@ describe("collection permissions", () => {
it("should be able to duplicate the dashboard without obstructions from the modal (metabase#15256)", () => {
duplicate("Orders in a dashboard");
});
it(
"should be able to duplicate the question (metabase#15255)",
{ tags: "@skip" },
() => {
duplicate("Orders");
},
);
});
describe("archive", () => {

View File

@@ -33,9 +33,6 @@ function createDashboardDetails({ parameters }) {
const TOAST_TIMEOUT = 16000;
const TOAST_MESSAGE =
"You can make this dashboard snappier by turning off auto-applying filters.";
const filterToggleLabel = "Auto-apply filters";
describe(
@@ -288,158 +285,6 @@ describe(
H.assertTableRowsCount(200);
});
});
it(
"should display a toast when a dashboard takes longer than 15s to load even without parameter values (but has parameters with default values)",
{ tags: "@skip" },
() => {
cy.clock();
openSlowDashboard();
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().within(() => {
cy.findByText(TOAST_MESSAGE).should("be.visible");
cy.button("Turn off").click();
cy.wait("@updateDashboard");
});
H.openDashboardSettingsSidebar();
H.sidesheet()
.findByLabelText(filterToggleLabel)
.should("not.be.checked");
// Gadget
const filterDefaultValue = FILTER_WITH_DEFAULT_VALUE.default[0];
H.filterWidget().findByText(filterDefaultValue).should("be.visible");
H.getDashboardCard().within(() => {
H.assertTableRowsCount(53);
});
},
);
it(
"should not display the toast when we clear out parameter default value",
{ tags: "@skip" },
() => {
cy.clock();
openSlowDashboard({ [FILTER_WITH_DEFAULT_VALUE.slug]: null });
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().should("not.exist");
H.getDashboardCard().within(() => {
H.assertTableRowsCount(200);
});
},
);
});
describe("auto-apply filter toast", () => {
it(
"should display a toast when a dashboard takes longer than 15s to load",
{ tags: "@skip" },
() => {
cy.clock();
createDashboard();
openSlowDashboard({ [FILTER.slug]: "Gadget" });
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().within(() => {
cy.findByText(TOAST_MESSAGE).should("be.visible");
cy.button("Turn off").click();
cy.wait("@updateDashboard");
});
H.openDashboardSettingsSidebar();
H.sidesheet()
.findByLabelText(filterToggleLabel)
.should("not.be.checked");
H.closeDashboardSettingsSidebar();
H.filterWidget().findByText("Gadget").should("be.visible");
H.getDashboardCard().within(() => {
H.assertTableRowsCount(53);
});
},
);
it(
"should display the toast indefinitely unless dismissing manually",
{ tags: "@skip" },
() => {
cy.clock();
createDashboard();
openSlowDashboard({ [FILTER.slug]: "Gadget" });
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().findByText(TOAST_MESSAGE).should("be.visible");
cy.tick(100 * TOAST_TIMEOUT);
H.undoToast().findByText(TOAST_MESSAGE).should("be.visible");
H.undoToast().icon("close").click();
H.undoToast().should("not.exist");
},
);
it(
"should not display the toast when auto applying filters is disabled",
{ tags: "@skip" },
() => {
cy.clock();
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
openSlowDashboard({ [FILTER.slug]: "Gadget" });
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().should("not.exist");
H.filterWidget().findByText("Gadget").should("be.visible");
H.getDashboardCard().within(() => {
H.assertTableRowsCount(53);
});
},
);
it(
"should not display the toast if there are no parameter values",
{ tags: "@skip" },
() => {
cy.clock();
createDashboard();
openSlowDashboard();
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().should("not.exist");
},
);
it(
"should not display the same toast twice for a dashboard",
{ tags: "@skip" },
() => {
cy.clock();
createDashboard();
openSlowDashboard({ [FILTER.slug]: "Gadget" });
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().within(() => {
cy.button("Turn off").should("be.visible");
cy.icon("close").click();
});
H.filterWidget().findByText("Gadget").click();
H.popover().within(() => {
cy.findByText("Widget").click();
cy.findByText("Update filter").click();
});
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().should("not.exist");
},
);
});
describe("no collection curate permission", () => {
@@ -456,19 +301,6 @@ describe(
H.dashboardHeader().icon("ellipsis").click();
H.popover().findByText("Edit settings").should("not.exist");
});
it(
"should not display a toast even when a dashboard takes longer than 15s to load",
{ tags: "@skip" },
() => {
cy.clock();
openSlowDashboard({ [FILTER.slug]: "Gadget" });
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().should("not.exist");
},
);
});
describe("embeddings", () => {
@@ -602,71 +434,6 @@ describe(
H.assertTableRowsCount(54);
});
});
it(
"should display a toast when a dashboard takes longer than 15s to load",
{ tags: "@skip" },
() => {
createDashboard();
// Not sure why I need to pass a date in this case, but it doesn't work without it.
cy.clock(Date.now());
openSlowFullAppEmbeddingDashboard({ [FILTER.slug]: "Gadget" });
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().within(() => {
cy.findByText(TOAST_MESSAGE).should("be.visible");
cy.button("Turn off").click();
cy.wait("@updateDashboard");
});
// In embedding we'll load bookmark after the dashboard is loaded, it's the opposite in normal app because bookmark is cached from somewhere else.
// And somehow, dashboard card query will be completed before the dashboard even start to load, and in entity loader it uses `setTimeout`,
// so to make sure callback in `setTimeout` is called, we need to advance the clock using cy.tick().
cy.tick();
H.openDashboardSettingsSidebar();
H.sidesheet()
.findByLabelText(filterToggleLabel)
.should("not.be.checked");
H.filterWidget().findByText("Gadget").should("be.visible");
H.getDashboardCard().within(() => {
H.assertTableRowsCount(53);
});
// Card result should be updated after manually updating the filter
H.filterWidget().icon("close").click();
H.applyFilterButton().should("be.visible").click();
H.getDashboardCard().within(() => {
H.assertTableRowsCount(200);
});
},
);
it(
"should not display a toast when a dashboard takes longer than 15s to load if users have no write access to a dashboard",
{ tags: "@skip" },
() => {
createDashboard();
cy.signIn("readonly");
// Not sure why I need to pass a date in this case, but it doesn't work without it.
cy.clock(Date.now());
openSlowFullAppEmbeddingDashboard({ [FILTER.slug]: "Gadget" });
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().should("not.exist");
// In embedding we'll load bookmark after the dashboard is loaded, it's the opposite in normal app because bookmark is cached from somewhere else.
// And somehow, dashboard card query will be completed before the dashboard even start to load, and in entity loader it uses `setTimeout`,
// so to make sure callback in `setTimeout` is called, we need to advance the clock using cy.tick().
cy.tick();
H.getDashboardCard().within(() => {
H.assertTableRowsCount(53);
});
},
);
});
});
},
@@ -753,21 +520,6 @@ const openDashboard = (params = {}) => {
H.visitDashboard("@dashboardId", { params });
};
const openSlowDashboard = (params = {}) => {
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
"cardQuery",
);
cy.get("@dashboardId").then((dashboardId) => {
return cy.visit({
url: `/dashboard/${dashboardId}`,
qs: params,
});
});
H.getDashboardCard().should("be.visible");
};
const openSlowPublicDashboard = (params = {}) => {
cy.intercept("GET", "/api/public/dashboard/*/dashcard/*/card/*").as(
"cardQuery",
@@ -798,21 +550,6 @@ const openSlowEmbeddingDashboard = (params = {}) => {
H.getDashboardCard().should("be.visible");
};
const openSlowFullAppEmbeddingDashboard = (params = {}) => {
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
"cardQuery",
);
cy.get("@dashboardId").then((dashboardId) => {
visitFullAppEmbeddingUrl({
url: `/dashboard/${dashboardId}`,
qs: params,
});
});
H.getDashboardCard().should("be.visible");
};
const visitFullAppEmbeddingUrl = ({ url, qs }) => {
cy.visit({
url,

View File

@@ -2204,3 +2204,60 @@ describe("issue 63176", () => {
});
});
});
describe("issue 64138", () => {
const MAP_QUESTION = {
query: {
"source-table": PEOPLE_ID,
},
display: "map",
displayIsLocked: true,
visualization_settings: {
"map.type": "pin",
"map.pin_type": "markers",
},
};
beforeEach(() => {
H.restore();
cy.signInAsNormalUser();
H.createQuestionAndDashboard({
questionDetails: MAP_QUESTION,
}).then(({ body: { dashboard_id } }) => {
H.visitDashboard(dashboard_id);
});
});
it("should hide map controls when editing dashboard (metabase#64138)", () => {
H.editDashboard();
cy.log("hovering the map should not show the zoom controls");
H.getDashboardCard(0)
.realHover({
position: "center",
})
.within(() => {
cy.findByLabelText("Zoom in").should("not.exist");
cy.findByText("Set as default view").should("be.visible").click();
});
cy.log("hovering marker icons should not open their tooltips");
getMarkerIcon(0).realHover();
H.popover({ skipVisibilityCheck: true }).should("not.exist");
cy.log("clicking marker icons should not navigate to the question");
getMarkerIcon(0).click({ force: true });
cy.location("pathname").should("match", /^\/dashboard\/[0-9]+$/);
H.modal().should("not.exist");
});
function getMarkerIcon(index) {
// pick the last one so it will be on top
return H.getDashboardCard(index)
.get(".leaflet-marker-icon")
.should("have.length.gt", 0)
.last();
}
});

View File

@@ -177,10 +177,16 @@ describe("scenarios > x-rays", { tags: "@slow" }, () => {
cy.button("Save this").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Your dashboard was saved");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("See it").click();
cy.log(
"'See it' link should be displayed both in the header and in the toast",
);
H.undoToast()
.should("contain", "Your dashboard was saved")
.and("contain", "See it");
cy.findByTestId("automatic-dashboard-header").within(() => {
cy.findByRole("link", { name: "See it" }).should("be.visible").click();
});
cy.url().should("contain", "a-look-at-orders");

View File

@@ -0,0 +1,678 @@
const { H } = cy;
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
const { PRODUCTS_ID } = SAMPLE_DATABASE;
H.describeWithSnowplowEE("documents", () => {
beforeEach(() => {
H.restore("postgres-writable");
H.resetTestTable({ type: "postgres", table: "many_schemas" });
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: "Animals" });
H.resetSnowplow();
cy.intercept("PUT", "/api/card/*").as("updateCard");
cy.intercept("PUT", "/api/native-query-snippet/*").as("updateSnippet");
cy.intercept("PUT", "/api/ee/transform/*").as("updateTransform");
});
afterEach(() => {
H.expectNoBadSnowplowEvents();
});
describe("questions", () => {
it("should be able to confirm or cancel breaking changes to a MBQL question", () => {
createMbqlQuestionWithDependentMbqlQuestions();
cy.log("make breaking changes");
H.visitQuestion("@questionId");
H.openNotebook();
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
cy.log("cancel breaking changes");
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
cy.findByText("Base question").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateCard.all").should("have.length", 0);
cy.log("confirm breaking changes");
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().button("Save anyway").click();
cy.wait("@updateCard");
});
it("should be able to navigate to affected questions or their collection", () => {
createMbqlQuestionWithDependentMbqlQuestions();
cy.log("check that we can navigate to the broken question");
H.visitQuestion("@questionId");
H.openNotebook();
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().findByText("Question with fields").click();
confirmDiscardChanges();
H.queryBuilderHeader()
.findByDisplayValue("Question with fields")
.should("be.visible");
cy.log("check that we can navigate to the collection of that question");
cy.go("back");
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Our analytics").click();
});
confirmDiscardChanges();
H.collectionTable().should("be.visible");
cy.log("check that we can navigate to the dashboard of that question");
cy.go("back");
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Orders in a dashboard").click();
});
confirmDiscardChanges();
H.dashboardHeader().should("be.visible");
});
it("should not show a confirmation if there are no breaking changes when updating a MBQL question", () => {
createMbqlQuestionWithDependentMbqlQuestions();
H.visitQuestion("@questionId");
H.openNotebook();
H.getNotebookStep("expression").findByText("Expr").click();
H.CustomExpressionEditor.clear().type("2 + 2");
H.popover().button("Update").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
cy.wait("@updateCard");
});
});
describe("models", () => {
it("should be able to confirm or cancel breaking changes to a SQL model", () => {
createSqlModelWithDependentSqlQuestions();
cy.log("make breaking changes");
cy.get<number>("@modelId").then(H.visitModel);
H.openQuestionActions("Edit query definition");
H.NativeEditor.clear().type("SELECT ID, TITLE FROM PRODUCTS");
H.runNativeQuery();
cy.log("cancel breaking changes");
H.datasetEditBar().button("Save changes").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
cy.findByText("Base model").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateCard.all").should("have.length", 0);
cy.log("confirm breaking changes");
H.datasetEditBar().button("Save changes").click();
H.modal().button("Save anyway").click();
cy.wait("@updateCard");
});
it("should not show a confirmation if there are no breaking changes when updating a SQL model", () => {
createSqlModelWithDependentSqlQuestions();
cy.get<number>("@modelId").then(H.visitModel);
H.openQuestionActions("Edit query definition");
H.NativeEditor.clear().type("SELECT ID, CATEGORY FROM PRODUCTS");
H.runNativeQuery();
H.datasetEditBar().button("Save changes").click();
cy.wait("@updateCard");
});
});
describe("metrics", () => {
it("should be able to confirm or cancel breaking changes to a metric", () => {
createMetricWithDependentMbqlQuestionsAndTransforms();
cy.log("make breaking changes");
cy.get<number>("@metricId").then(H.visitMetric);
H.openQuestionActions("Edit metric definition");
H.getNotebookStep("summarize").findByText("Min of Score").click();
H.popover().within(() => {
cy.icon("chevronleft").click();
cy.findByText("Maximum of ...").click();
cy.findByText("Score").click();
});
cy.log("cancel breaking changes");
cy.findByTestId("edit-bar").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Question with 2 stages").should("be.visible");
cy.findByText("Transform with 2 stages").should("be.visible");
cy.findByText("Question with 1 stage").should("not.exist");
cy.findByText("Transform with 1 stage").should("not.exist");
cy.findByText("Base metric").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateCard.all").should("have.length", 0);
cy.log("confirm breaking changes");
cy.findByTestId("edit-bar").button("Save changes").click();
H.modal().button("Save anyway").click();
cy.wait("@updateCard");
});
it("should not show a warning when a change to a metric is backward-compatible with existing content", () => {
createMetricWithDependentMbqlQuestionsAndTransforms();
cy.get<number>("@metricId").then(H.visitMetric);
H.openQuestionActions("Edit metric definition");
H.getNotebookStep("summarize").findByText("Min of Score").click();
H.popover().findByText("Name").click();
cy.findByTestId("edit-bar").button("Save changes").click();
cy.wait("@updateCard");
});
});
describe("transforms", () => {
it("should be able to confirm or cancel breaking changes to a SQL transform after it was run", () => {
createSqlTransformWithDependentMbqlQuestions();
cy.log("make breaking changes");
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.NativeEditor.clear().type('SELECT name FROM "Schema A"."Animals"');
cy.log("cancel breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
cy.findByText("Base transform").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateTransform.all").should("have.length", 0);
cy.log("confirm breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
cy.findByText("Base transform").should("not.exist");
cy.button("Save anyway").click();
});
cy.wait("@updateTransform");
});
it("should not show a confirmation if there are no breaking changes when updating a SQL transform after it was run", () => {
createSqlTransformWithDependentMbqlQuestions();
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.NativeEditor.clear().type(
'SELECT score, name FROM "Schema A"."Animals"',
);
cy.findByTestId("transform-query-editor").button("Save changes").click();
cy.wait("@updateTransform");
});
it("should be able to confirm or cancel breaking changes to a MBQL transform before it was run", () => {
createMbqlTransformWithDependentSqlTransforms();
cy.log("make breaking changes");
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.getNotebookStep("data").findByLabelText("Pick columns").click();
H.popover().findByLabelText("Score").click();
cy.log("cancel breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Score transform").should("be.visible");
cy.findByText("Name transform").should("not.exist");
cy.findByText("Base transform").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateTransform.all").should("have.length", 0);
cy.log("confirm breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Score transform").should("be.visible");
cy.findByText("Name transform").should("not.exist");
cy.button("Save anyway").click();
});
cy.wait("@updateTransform");
});
it("should not show a confirmation if there are no breaking changes when updating a MBQL transform before it was run", () => {
createMbqlTransformWithDependentSqlTransforms();
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.getNotebookStep("data").button("Sort").click();
H.popover().findByText("Score").click();
cy.findByTestId("transform-query-editor").button("Save changes").click();
cy.wait("@updateTransform");
});
});
describe("snippets", () => {
it("should be able to confirm or cancel breaking changes to a snippet", () => {
createSnippetWithDependentQuestionsAndTransforms();
cy.log("make breaking changes");
startEditingSnippet();
H.modal()
.findByLabelText("Enter some SQL here so you can reuse it later")
.clear()
.type("price = 1");
cy.log("cancel breaking changes");
H.modal().within(() => {
cy.button("Save").click();
cy.findByText("Question with snippet").should("be.visible");
cy.findByText("Transform with snippet").should("be.visible");
cy.findByText("ScoreSnippet").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateSnippet.all").should("have.length", 0);
cy.log("confirm breaking changes");
H.modal().within(() => {
cy.button("Save").click();
cy.findByText("Question with snippet").should("be.visible");
cy.findByText("Transform with snippet").should("be.visible");
cy.findByText("ScoreSnippet").should("not.exist");
cy.button("Save anyway").click();
});
cy.wait("@updateSnippet");
});
it("should not warn if changes to a snippet are backward compatible", () => {
createSnippetWithDependentQuestionsAndTransforms();
startEditingSnippet();
H.modal().within(() => {
cy.findByLabelText("Enter some SQL here so you can reuse it later")
.clear()
.type("score = 2");
cy.button("Save").click();
});
cy.wait("@updateSnippet");
});
});
});
function createMbqlQuestionWithDependentMbqlQuestions() {
H.createQuestion({
name: "Base question",
query: {
"source-table": PRODUCTS_ID,
expressions: {
Expr: ["+", 1, 1],
},
},
}).then(({ body: card }) => {
cy.wrap(card.id).as("questionId");
H.createQuestion({
name: "Question with fields",
query: {
"source-table": `card__${card.id}`,
filter: [">", ["field", "Expr", { "base-type": "type/Integer" }], 1],
},
dashboard_id: ORDERS_DASHBOARD_ID,
});
H.createQuestion({
name: "Question without fields",
query: {
"source-table": `card__${card.id}`,
},
});
});
}
function createSqlModelWithDependentSqlQuestions() {
H.createNativeQuestion({
name: "Base model",
type: "model",
native: {
query: "SELECT ID, TITLE, CATEGORY FROM PRODUCTS",
"template-tags": {},
},
}).then(({ body: card }) => {
cy.wrap(card.id).as("modelId");
cy.request("POST", `/api/card/${card.id}/query`);
const tagName = `#${card.id}`;
H.createNativeQuestion({
name: "Question with fields",
native: {
query: `SELECT ID, CATEGORY FROM {{#${card.id}}}`,
"template-tags": {
[tagName]: {
id: "10422a0f-292d-10a3-fd90-407cc9e3e20e",
name: tagName,
"display-name": tagName,
type: "card",
"card-id": card.id,
},
},
},
});
H.createNativeQuestion({
name: "Question without fields",
native: {
query: `SELECT COUNT(*) FROM {{#${card.id}}}`,
"template-tags": {
[tagName]: {
id: "10422a0f-292d-10a3-fd90-407cc9e3e20f",
name: tagName,
"display-name": tagName,
type: "card",
"card-id": card.id,
},
},
},
});
});
}
function createMetricWithDependentMbqlQuestionsAndTransforms() {
H.getTableId({ databaseId: WRITABLE_DB_ID, name: "Animals" }).then(
(tableId) => {
H.getFieldId({ tableId, name: "score" }).then((fieldId) => {
H.createQuestion({
name: "Base metric",
type: "metric",
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
aggregation: [["min", ["field", fieldId, null]]],
},
}).then(({ body: metric }) => {
cy.wrap(metric.id).as("metricId");
H.createQuestion({
name: "Question with 1 stage",
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
aggregation: [["metric", metric.id]],
},
});
H.createQuestion({
name: "Question with 2 stages",
database: WRITABLE_DB_ID,
query: {
"source-query": {
"source-table": tableId,
aggregation: [["metric", metric.id]],
breakout: [["field", fieldId, null]],
},
aggregation: [
["avg", ["field", "min", { "base-type": "type/Integer" }]],
],
},
});
H.createTransform({
name: "Transform with 1 stage",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "query",
query: {
"source-table": tableId,
aggregation: [["metric", metric.id]],
},
},
},
target: {
type: "table",
name: "transform_1_stage",
schema: "public",
database: WRITABLE_DB_ID,
},
});
H.createTransform({
name: "Transform with 2 stages",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "query",
query: {
"source-query": {
"source-table": tableId,
aggregation: [["metric", metric.id]],
breakout: [["field", fieldId, null]],
},
aggregation: [
["avg", ["field", "min", { "base-type": "type/Integer" }]],
],
},
},
},
target: {
type: "table",
name: "transform_2_stages",
schema: "public",
database: WRITABLE_DB_ID,
},
});
});
});
},
);
}
function createSqlTransformWithDependentMbqlQuestions() {
const transformTableName = "base_transform";
H.createTransform({
name: "Base transform",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "native",
native: {
query: 'SELECT name, score FROM "Schema A"."Animals"',
"template-tags": {},
},
},
},
target: {
type: "table",
name: transformTableName,
schema: "public",
database: WRITABLE_DB_ID,
},
}).then(({ body: transform }) => {
cy.wrap(transform.id).as("transformId");
cy.request("POST", `/api/ee/transform/${transform.id}/run`);
H.waitForSucceededTransformRuns();
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: transformTableName });
H.getTableId({ databaseId: WRITABLE_DB_ID, name: transformTableName }).then(
(tableId) => {
H.createQuestion({
name: "Question with fields",
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
filter: [
">",
["field", "score", { "base-type": "type/Integer" }],
1,
],
},
});
H.createQuestion({
name: "Question without fields",
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
},
});
},
);
});
}
function createMbqlTransformWithDependentSqlTransforms() {
H.getTableId({ name: "Animals", databaseId: WRITABLE_DB_ID }).then(
(tableId) => {
H.createTransform(
{
name: "Base transform",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "query",
query: {
"source-table": tableId,
},
},
},
target: {
type: "table",
name: "base_transform",
schema: "public",
database: WRITABLE_DB_ID,
},
},
{ wrapId: true },
);
H.createTransform({
name: "Name transform",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "native",
native: {
query: "SELECT name FROM base_transform",
"template-tags": {},
},
},
},
target: {
type: "table",
name: "name_transform",
schema: "public",
database: WRITABLE_DB_ID,
},
});
H.createTransform({
name: "Score transform",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "native",
native: {
query: "SELECT score FROM base_transform",
"template-tags": {},
},
},
},
target: {
type: "table",
name: "score_transform",
schema: "public",
database: WRITABLE_DB_ID,
},
});
},
);
}
function createSnippetWithDependentQuestionsAndTransforms() {
H.createSnippet({
name: "ScoreSnippet",
content: "score = 1",
}).then(({ body: snippet }) => {
cy.wrap(snippet.id).as("snippetId");
H.createNativeQuestion({
name: "Question with snippet",
database: WRITABLE_DB_ID,
native: {
query:
'SELECT * FROM "Schema A"."Animals" WHERE {{snippet:ScoreSnippet}}',
"template-tags": {
"snippet:ScoreSnippet": {
id: "4b77cc1f-ea70-4ef6-84db-58432fce6928",
name: "snippet:ScoreSnippet",
"display-name": "snippet:ScoreSnippet",
type: "snippet",
"snippet-id": snippet.id,
"snippet-name": snippet.name,
},
},
},
});
H.createTransform({
name: "Transform with snippet",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "native",
native: {
query:
'SELECT * FROM "Schema A"."Animals" WHERE {{snippet:ScoreSnippet}}',
"template-tags": {
"snippet:ScoreSnippet": {
id: "4b77cc1f-ea70-4ef6-84db-58432fce6929",
name: "snippet:ScoreSnippet",
"display-name": "snippet:ScoreSnippet",
type: "snippet",
"snippet-id": snippet.id,
"snippet-name": snippet.name,
},
},
},
},
},
target: {
type: "table",
name: "transform_with_snippet",
schema: "public",
database: WRITABLE_DB_ID,
},
});
});
}
function startEditingSnippet() {
H.startNewNativeQuestion();
cy.findByTestId("native-query-editor-action-buttons").icon("snippet").click();
cy.findByTestId("sidebar-content").within(() => {
cy.icon("chevrondown").click({ force: true });
cy.button(/Edit/).click();
});
}
function confirmDiscardChanges() {
H.modal().should("have.length", 2).last().button("Discard changes").click();
}

View File

@@ -23,12 +23,12 @@ describe("scenarios - embedding hub", () => {
.should("exist");
});
it('"Generate a dashboard" card should work correctly', () => {
it('"Create a dashboard" card should work correctly', () => {
cy.visit("/admin/embedding/setup-guide");
cy.log("Find and click on 'Generate a dashboard' card");
cy.log("Find and click on 'Create a dashboard' card");
cy.findByTestId("admin-layout-content")
.findByText("Generate a dashboard")
.findByText("Create a dashboard")
.click();
cy.log("Select a table to generate dashboard from");
@@ -44,11 +44,13 @@ describe("scenarios - embedding hub", () => {
cy.url().should("include", "/auto/dashboard/table/");
});
it('"Add data" card should work correctly', () => {
it('"Connect a database" card should work correctly', () => {
cy.visit("/admin/embedding/setup-guide");
cy.log("Find and click on 'Add data' card");
cy.findByTestId("admin-layout-content").findByText("Add data").click();
cy.log("Find and click on 'Connect a database' card");
cy.findByTestId("admin-layout-content")
.findByText("Connect a database")
.click();
cy.log("Add data modal should open");
cy.findByRole("dialog").within(() => {
@@ -70,14 +72,16 @@ describe("scenarios - embedding hub", () => {
cy.visit("/admin/embedding/setup-guide");
cy.log("'Add data' should not be marked as done");
cy.log("'Connect a database' should not be marked as done");
cy.findByTestId("admin-layout-content")
.findByText("Add data")
.findByText("Connect a database")
.closest("button")
.findByText("Done")
.should("not.exist");
cy.findByTestId("admin-layout-content").findByText("Add data").click();
cy.findByTestId("admin-layout-content")
.findByText("Connect a database")
.click();
H.modal().within(() => {
cy.findByText("CSV").click();
@@ -101,9 +105,9 @@ describe("scenarios - embedding hub", () => {
cy.wait("@getChecklist");
cy.log("'Add data' should be marked as done");
cy.log("'Connect a database' should be marked as done");
cy.findByTestId("admin-layout-content")
.findByText("Add data")
.findByText("Connect a database")
.closest("button")
.scrollIntoView()
.findByText("Done")
@@ -143,11 +147,11 @@ describe("scenarios - embedding hub", () => {
});
});
it('"Embed in your code" card should take you to the embed flow', () => {
it('"Get embed snippet" card should take you to the embed flow', () => {
cy.visit("/admin/embedding/setup-guide");
cy.findByTestId("admin-layout-content")
.findByText("Embed in your code")
.findByText("Get embed snippet")
.click();
cy.url().should("include", "/embed-js?auth_method=user_session");
@@ -189,8 +193,8 @@ describe("scenarios - embedding hub", () => {
cy.get("main").within(() => {
cy.findByText("Create models").should("be.visible");
cy.findByText("Generate a dashboard").should("be.visible");
cy.findByText("Add data").should("be.visible").click();
cy.findByText("Create a dashboard").should("be.visible");
cy.findByText("Connect a database").should("be.visible").click();
});
cy.log("Sanity check: add data modal should open");

View File

@@ -104,14 +104,6 @@ H.describeWithSnowplow(suiteTitle, () => {
.findByLabelText("Product ID")
.should("contain", "456");
H.expectUnstructuredSnowplowEvent(
{
event: "embed_wizard_option_changed",
event_detail: "initialParameters",
},
2,
);
cy.log("both default values should be in the code snippet");
getEmbedSidebar().within(() => {
cy.findByText("Get Code").click();
@@ -147,14 +139,6 @@ H.describeWithSnowplow(suiteTitle, () => {
.findByTestId("dashboard-parameters-widget-container")
.should("not.exist");
H.expectUnstructuredSnowplowEvent(
{
event: "embed_wizard_option_changed",
event_detail: "hiddenParameters",
},
2,
);
cy.log("code snippet should contain the hidden parameters");
getEmbedSidebar().within(() => {
cy.findByText("Get Code").click();
@@ -218,11 +202,6 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.findAllByText("75.41").first().should("be.visible");
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "initialSqlParameters",
});
getEmbedSidebar().within(() => {
cy.findByText("Get Code").click();
codeBlock().should("contain", "initial-sql-parameters=");

View File

@@ -115,6 +115,32 @@ H.describeWithSnowplow(suiteTitle, () => {
getEmbedSidebar().within(() => {
cy.findByLabelText("Existing Metabase session").should("be.checked");
codeBlock().should("contain", '"useExistingUserSession": true');
cy.findByText(/Copy Code/).click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_code_copied",
event_detail: "user_session",
});
});
});
it("should track embed_wizard_code_copied when copy event triggers", () => {
navigateToGetCodeStep({
experience: "dashboard",
resourceName: DASHBOARD_NAME,
});
getEmbedSidebar().within(() => {
cy.findByLabelText("Existing Metabase session").should("be.checked");
codeBlock().should("contain", '"useExistingUserSession": true');
codeBlock().trigger("copy");
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_code_copied",
event_detail: "user_session",
});
});
});
@@ -127,13 +153,14 @@ H.describeWithSnowplow(suiteTitle, () => {
getEmbedSidebar().within(() => {
cy.findByLabelText("Single sign-on (SSO)").click();
codeBlock().should("not.contain", "useExistingUserSession");
cy.findByText(/Copy Code/).click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_auth_selected",
event: "embed_wizard_code_copied",
event_detail: "sso",
});
codeBlock().should("not.contain", "useExistingUserSession");
});
});

View File

@@ -41,7 +41,25 @@ H.describeWithSnowplow(suiteTitle, () => {
H.expectNoBadSnowplowEvents();
});
it("can select a recent dashboard to embed", () => {
it("tracks 'default' when keeping the default dashboard selection", () => {
visitNewEmbedPage();
getEmbedSidebar().within(() => {
cy.findByText("Next").click();
cy.findByText("Select a dashboard to embed").should("be.visible");
cy.log("first dashboard should be selected by default");
getRecentItemCards().first().should("have.attr", "data-selected", "true");
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "default",
});
});
it("tracks 'custom' when selecting a different dashboard", () => {
cy.log("add two dashboards to activity log");
H.createDashboard({ name: SECOND_DASHBOARD_NAME }).then(
@@ -70,14 +88,6 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.log("second dashboard can be selected");
cy.findByText(SECOND_DASHBOARD_NAME).click();
cy.get("@secondDashboardId").then((secondDashboardId) => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selected",
target_id: secondDashboardId,
event_detail: "dashboard",
});
});
getRecentItemCards().eq(1).should("have.attr", "data-selected", "true");
});
@@ -87,22 +97,11 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.findByText(SECOND_DASHBOARD_NAME).should("be.visible");
});
cy.log("select the same the dashboard again");
getEmbedSidebar().within(() => {
cy.findByText(SECOND_DASHBOARD_NAME).click();
cy.findByText(SECOND_DASHBOARD_NAME).click();
});
getEmbedSidebar().findByText("Next").click();
cy.get("@secondDashboardId").then((secondDashboardId) => {
cy.log("only a single snowplow event should be sent");
H.expectUnstructuredSnowplowEvent(
{
event: "embed_wizard_resource_selected",
target_id: secondDashboardId,
event_detail: "dashboard",
},
1,
);
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "custom",
});
});
@@ -131,13 +130,14 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.log("second question can be selected");
cy.findByText(SECOND_QUESTION_NAME).click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selected",
target_id: ORDERS_BY_YEAR_QUESTION_ID,
event_detail: "chart",
});
getRecentItemCards().eq(1).should("have.attr", "data-selected", "true");
cy.findByText("Next").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "custom",
});
});
cy.log("selected question should be shown in the preview");
@@ -166,14 +166,6 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.findByText(SECOND_DASHBOARD_NAME).click();
});
cy.get("@secondDashboardId").then((secondDashboardId) => {
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selected",
target_id: secondDashboardId,
event_detail: "dashboard",
});
});
cy.log("dashboard is added to the top of recents list and selected");
getEmbedSidebar().within(() => {
getRecentItemCards()
@@ -204,12 +196,6 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.findByText(FIRST_QUESTION_NAME).click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selected",
target_id: ORDERS_COUNT_QUESTION_ID,
event_detail: "chart",
});
cy.log("question is added to the top of recents list and selected");
getEmbedSidebar().within(() => {
getRecentItemCards()
@@ -217,6 +203,13 @@ H.describeWithSnowplow(suiteTitle, () => {
.first()
.should("contain", FIRST_QUESTION_NAME)
.should("have.attr", "data-selected", "true");
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "custom",
});
cy.wait("@cardQuery");

View File

@@ -39,8 +39,18 @@ H.describeWithSnowplow(suiteTitle, () => {
visitNewEmbedPage();
assertRecentItemName("dashboard", dashboardName);
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
H.waitForSimpleEmbedIframesToLoad();
getEmbedSidebar().within(() => {
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_experience_completed",
event_detail: "default",
});
H.getSimpleEmbedIframeContent().within(() => {
cy.log("dashboard title is visible");
cy.findByText(dashboardName).should("be.visible");
@@ -59,12 +69,16 @@ H.describeWithSnowplow(suiteTitle, () => {
visitNewEmbedPage();
assertRecentItemName("card", questionName);
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
getEmbedSidebar().findByText("Chart").click();
getEmbedSidebar().within(() => {
cy.findByText("Chart").click();
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_experience_selected",
event_detail: "chart",
event: "embed_wizard_experience_completed",
event_detail: "custom=chart",
});
cy.wait("@cardQuery");
@@ -77,11 +91,15 @@ H.describeWithSnowplow(suiteTitle, () => {
it("shows exploration template when selected", { tags: "@skip" }, () => {
visitNewEmbedPage();
getEmbedSidebar().findByText("Exploration").click();
getEmbedSidebar().within(() => {
cy.findByText("Exploration").click();
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_experience_selected",
event_detail: "exploration",
event: "embed_wizard_experience_completed",
event_detail: "custom=exploration",
});
H.waitForSimpleEmbedIframesToLoad();
@@ -94,11 +112,15 @@ H.describeWithSnowplow(suiteTitle, () => {
it("shows browser template when selected", () => {
visitNewEmbedPage();
getEmbedSidebar().findByText("Browser").click();
getEmbedSidebar().within(() => {
cy.findByText("Browser").click();
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_experience_selected",
event_detail: "browser",
event: "embed_wizard_experience_completed",
event_detail: "custom=browser",
});
H.getSimpleEmbedIframeContent().within(() => {
@@ -144,11 +166,14 @@ H.describeWithSnowplow(suiteTitle, () => {
visitNewEmbedPage();
cy.wait("@emptyRecentItems");
getEmbedSidebar().findByText("Chart").click();
getEmbedSidebar().within(() => {
cy.findByText("Chart").click();
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_experience_selected",
event_detail: "chart",
event: "embed_wizard_experience_completed",
event_detail: "custom=chart",
});
H.waitForSimpleEmbedIframesToLoad();

View File

@@ -55,11 +55,6 @@ H.describeWithSnowplow(suiteTitle, () => {
.click()
.should("not.be.checked");
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "drills",
});
cy.log("drill-through should be disabled in the preview");
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText("110.93").click();
@@ -68,6 +63,13 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.log("snippet should be updated");
getEmbedSidebar().findByText("Get Code").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,drills=false,withDownloads=false,withTitle=true",
});
codeBlock().should("contain", 'drills="false"');
});
@@ -91,17 +93,19 @@ H.describeWithSnowplow(suiteTitle, () => {
.click()
.should("be.checked");
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "withDownloads",
});
H.getSimpleEmbedIframeContent()
.findByTestId("export-as-pdf-button")
.should("be.visible");
cy.log("snippet should be updated");
getEmbedSidebar().findByText("Get Code").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,drills=true,withDownloads=true,withTitle=true",
});
codeBlock().should("contain", 'with-downloads="true"');
});
@@ -125,17 +129,19 @@ H.describeWithSnowplow(suiteTitle, () => {
.click()
.should("not.be.checked");
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "withTitle",
});
H.getSimpleEmbedIframeContent()
.findByText("Orders in a dashboard")
.should("not.exist");
cy.log("snippet should be updated");
getEmbedSidebar().findByText("Get Code").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,drills=true,withDownloads=false,withTitle=false",
});
codeBlock().should("contain", 'with-title="false"');
});
@@ -161,11 +167,6 @@ H.describeWithSnowplow(suiteTitle, () => {
.click()
.should("not.be.checked");
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "drills",
});
cy.log("drill-through should be disabled in chart preview");
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText("18,760").click();
@@ -200,17 +201,19 @@ H.describeWithSnowplow(suiteTitle, () => {
.click()
.should("be.checked");
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "withDownloads",
});
H.getSimpleEmbedIframeContent()
.findByTestId("question-download-widget-button")
.should("be.visible");
cy.log("snippet should be updated");
getEmbedSidebar().findByText("Get Code").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,drills=true,withDownloads=true,withTitle=true,isSaveEnabled=false",
});
codeBlock().should("contain", 'with-downloads="true"');
});
@@ -233,11 +236,6 @@ H.describeWithSnowplow(suiteTitle, () => {
.click()
.should("not.be.checked");
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "withTitle",
});
H.getSimpleEmbedIframeContent()
.findByText("Orders, Count")
.should("not.exist");
@@ -277,22 +275,15 @@ H.describeWithSnowplow(suiteTitle, () => {
H.getSimpleEmbedIframeContent()
.findByText("Orders, Count")
.should("be.visible");
H.expectUnstructuredSnowplowEvent(
{
event: "embed_wizard_option_changed",
event_detail: "withTitle",
},
2,
);
});
["exploration", "chart"].forEach((experience) => {
it(`toggles save button for ${experience}`, () => {
navigateToEmbedOptionsStep({
experience,
...(experience === "chart" && { resourceName: QUESTION_NAME }),
});
navigateToEmbedOptionsStep(
experience === "chart"
? { experience: "chart", resourceName: QUESTION_NAME }
: { experience: "exploration" },
);
if (experience === "exploration") {
cy.log("visualize a question to enable the save button");
@@ -315,11 +306,6 @@ H.describeWithSnowplow(suiteTitle, () => {
.click()
.should("be.checked");
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "isSaveEnabled",
});
if (experience === "chart") {
cy.log("select a different visualization to enable the save button");
H.getSimpleEmbedIframeContent().within(() => {
@@ -334,6 +320,15 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.log("snippet should be updated");
getEmbedSidebar().findByText("Get Code").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
experience === "chart"
? "settings=custom,theme=default,auth=user_session,drills=true,withDownloads=false,withTitle=true,isSaveEnabled=true"
: "settings=custom,theme=default,auth=user_session,isSaveEnabled=true",
});
codeBlock().should("contain", 'is-save-enabled="true"');
});
});
@@ -358,17 +353,19 @@ H.describeWithSnowplow(suiteTitle, () => {
.click()
.should("be.checked");
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "readOnly",
});
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText("New dashboard").should("be.visible");
});
cy.log("snippet should be updated");
getEmbedSidebar().findByText("Get Code").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=default,auth=user_session,readOnly=false",
});
codeBlock().should("contain", 'read-only="false"');
});
@@ -397,11 +394,6 @@ H.describeWithSnowplow(suiteTitle, () => {
.type("rgb(255, 0, 0)");
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "theme",
});
cy.log("table header cell should now be red");
H.getSimpleEmbedIframeContent()
.findAllByTestId("cell-data")
@@ -414,6 +406,12 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.log("snippet should be updated");
getEmbedSidebar().findByText("Get Code").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=custom,auth=user_session,drills=true,withDownloads=false,withTitle=true",
});
codeBlock().should("contain", '"theme": {');
codeBlock().should("contain", '"colors": {');
codeBlock().should("contain", '"brand": "#FF0000"');
@@ -424,11 +422,6 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.log("click reset button");
getEmbedSidebar().findByLabelText("Reset colors").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_option_changed",
event_detail: "theme",
});
cy.log("table header should be back to default blue");
H.getSimpleEmbedIframeContent()
.findAllByTestId("cell-data")
@@ -441,6 +434,11 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.log("snippet should not contain theme colors");
getEmbedSidebar().findByText("Get Code").click();
codeBlock().should("not.contain", '"theme": {');
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail: "settings=default",
});
});
it("derives colors for dark theme palette", () => {
@@ -475,6 +473,12 @@ H.describeWithSnowplow(suiteTitle, () => {
cy.log("check that derived colors are applied to snippet");
getEmbedSidebar().findByText("Get Code").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail:
"settings=custom,theme=custom,auth=user_session,drills=true,withDownloads=false,withTitle=true",
});
// derived-colors-for-embed-flow.unit.spec.ts contains the tests for other derived colors.
cy.log("dark mode colors should be derived");
codeBlock().should("contain", '"background-hover": "rgb(27, 27, 27)"');

View File

@@ -1,5 +1,7 @@
import { USERS } from "e2e/support/cypress_data";
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
import {
getSignedJwtForUser,
mockAuthSsoEndpointForSamlAuthProvider,
stubWindowOpenForSamlPopup,
} from "e2e/support/helpers/embedding-sdk-testing";
@@ -124,6 +126,62 @@ describe("scenarios > embedding > sdk iframe embedding > authentication", () =>
assertDashboardLoaded(frame);
});
it("can login via JWT and a custom fetch request token function", () => {
H.prepareSdkIframeEmbedTest({ enabledAuthMethods: ["jwt"], signOut: true });
const frame = H.loadSdkIframeEmbedTestPage({
onVisitPage: (win) => {
(win as any).metabaseConfig = {
...(win as any).metabaseConfig,
fetchRequestToken: async () => {
const jwt = await getSignedJwtForUser({ user: USERS.admin });
return { jwt };
},
};
},
elements: [
{
component: "metabase-dashboard",
attributes: {
dashboardId: ORDERS_DASHBOARD_ID,
},
},
],
});
assertDashboardLoaded(frame);
});
it("shows error message if login via JWT and a custom fetch request token is failing", () => {
H.prepareSdkIframeEmbedTest({ enabledAuthMethods: ["jwt"], signOut: true });
const frame = H.loadSdkIframeEmbedTestPage({
onVisitPage: (win) => {
(win as any).metabaseConfig = {
...(win as any).metabaseConfig,
fetchRequestToken: async () => {
return { jwt: "" };
},
};
},
elements: [
{
component: "metabase-dashboard",
attributes: {
dashboardId: ORDERS_DASHBOARD_ID,
},
},
],
});
frame.within(() => {
cy.findByTestId("sdk-error-container")
.findByText(/Failed to fetch JWT token/)
.should("exist");
});
});
it("can login via SAML", () => {
mockAuthSsoEndpointForSamlAuthProvider();
H.prepareSdkIframeEmbedTest({ enabledAuthMethods: [], signOut: true });

View File

@@ -0,0 +1,97 @@
const { H } = cy;
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
import type { BaseEmbedTestPageOptions } from "e2e/support/helpers";
H.describeWithSnowplowEE(
"scenarios > embedding > sdk iframe embedding > pdf downloads",
() => {
const setup = (options: Partial<BaseEmbedTestPageOptions>) => {
const frame = H.loadSdkIframeEmbedTestPage({
elements: [
{
component: "metabase-dashboard",
attributes: {
dashboardId: ORDERS_DASHBOARD_ID,
withDownloads: true,
},
},
],
...options,
});
cy.wait("@getDashCardQuery");
// Check that the dashboard loaded fine
frame.within(() => {
cy.findByText("Orders in a dashboard").should("be.visible");
cy.findByText("Orders").should("be.visible");
H.assertTableRowsCount(2000);
});
return frame;
};
beforeEach(() => {
H.resetSnowplow();
cy.deleteDownloadsFolder();
});
afterEach(() => {
H.expectNoBadSnowplowEvents();
});
describe("Dashboard PDF downloads", () => {
beforeEach(() => {
H.prepareSdkIframeEmbedTest({ signOut: true });
});
it("should download dashboard as PDF with analytics tracking", () => {
const frame = setup({
metabaseConfig: {
theme: {
components: {
dashboard: {
backgroundColor: "transparent",
},
},
},
},
});
frame
.should("contain", "Orders in a dashboard")
.findByLabelText("Download as PDF")
.should("be.visible")
.click();
// Verify PDF file download
cy.verifyDownload("Orders in a dashboard.pdf");
// Verify analytics tracking
H.expectUnstructuredSnowplowEvent({
dashboard_accessed_via: "sdk-embed",
event: "dashboard_pdf_exported",
});
});
it("should hide PDF download button when with-downloads is false", () => {
const frame = setup({
elements: [
{
component: "metabase-dashboard",
attributes: {
dashboardId: ORDERS_DASHBOARD_ID,
withDownloads: false,
},
},
],
});
frame
.should("contain", "Orders in a dashboard")
.findByLabelText("Download as PDF")
.should("not.exist");
});
});
},
);

View File

@@ -225,14 +225,14 @@ describe("scenarios > metrics > collection", () => {
H.createQuestion({
...ORDERS_SCALAR_MODEL_METRIC,
collection_id: FIRST_COLLECTION_ID,
}).then(({ body: card }) => {
}).then(() => {
cy.signIn("nocollection");
H.visitCollection(FIRST_COLLECTION_ID);
});
H.getPinnedSection()
.findByTestId("scalar-container")
.findByText("18,760")
.should("be.visible");
.findByTestId("scalar-value")
.should("not.be.empty")
.and("be.visible");
});
});

View File

@@ -689,7 +689,7 @@ describe("issue 23421", () => {
type: "model",
};
const hiddenColumnsQuestionDetails = {
const hiddenColumnsModelDetails = {
native: {
query,
},
@@ -734,16 +734,17 @@ describe("issue 23421", () => {
});
it("`visualization_settings` with hidden columns should not break UI (metabase#23421)", () => {
H.createNativeQuestion(hiddenColumnsQuestionDetails, {
H.createNativeQuestion(hiddenColumnsModelDetails, {
visitQuestion: true,
});
H.openQuestionActions();
H.popover().findByText("Edit query definition").click();
H.NativeEditor.get().should("be.visible").and("contain", query);
cy.findByTestId("visualization-root")
.findByText("Every field is hidden right now")
.should("be.visible");
H.tableInteractiveHeader().within(() => {
cy.findByText("id").should("be.visible");
cy.findByText("created_at").should("be.visible");
});
cy.button("Save changes").should("be.disabled");
});
});

View File

@@ -818,3 +818,38 @@ describe("issue 59356", () => {
cy.get("@dataset.all").should("have.length", 2);
});
});
describe("issue 63711", () => {
beforeEach(() => {
H.restore();
cy.signInAsNormalUser();
});
it("Completions should be visible when there are a lot of options (metabase#63711)", () => {
H.startNewNativeQuestion();
H.NativeEditor.type("s");
cy.log("completions should be scrollable");
H.NativeEditor.completions()
.findByLabelText("Completions")
.then(($el) => {
const element = $el[0];
cy.wrap(element.scrollHeight).should("be.gt", element.clientHeight);
});
cy.log("completions should not cut off the height of the inner element");
H.NativeEditor.completion("SAVEPOINT")
.should("be.visible")
.then(($outerElement) => {
cy.wrap($outerElement)
.findByText("AVEPOINT")
.should("be.visible")
.then(($innerElement) => {
cy.wrap($innerElement[0].offsetHeight).should(
"be.eq",
$outerElement[0].clientHeight,
);
});
});
});
});

View File

@@ -314,6 +314,19 @@ describe("scenarios > setup", () => {
.should("be.visible");
});
// There are only one step in the setup flow, so there is no need to show step numbers.
it("should not show step numbers in cloud embedding use case", () => {
H.mockSessionProperty("is-hosted?", true);
H.mockSessionProperty("token-features", { hosting: true });
cy.visit(
"/setup?first_name=John&last_name=Doe&email=john@doe.test&site_name=Doe%20Unlimited&use_case=embedding",
);
H.main().findByText("What should we call you?").should("be.visible");
cy.findByTestId("step-number").should("not.exist");
});
it("should allow localization in the 'embedding' setup flow", () => {
cy.visit(
"/setup?first_name=John&last_name=Doe&email=john@doe.test&site_name=Doe%20Unlimited&use_case=embedding",

View File

@@ -436,6 +436,8 @@ describe("issue 28106", () => {
.findByTestId("scroll-container")
.as("schemasList");
H.entityPickerModalLevel(2).should("contain", "Animals");
cy.get("@schemasList").scrollTo("bottom");
// assert scrolling worked and the last item is visible

View File

@@ -7,14 +7,22 @@ import { getEmbedSidebar } from "../embedding/sdk-iframe-embedding-setup/helpers
const { H } = cy;
describe("embed flow pre-selection from sharing modal", () => {
const suiteTitle = "scenarios > sharing > embed flow pre-selection";
H.describeWithSnowplow(suiteTitle, () => {
beforeEach(() => {
H.restore();
H.resetSnowplow();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
H.activateToken("bleeding-edge");
H.enableTracking();
H.updateSetting("enable-embedding-simple", true);
});
afterEach(() => {
H.expectNoBadSnowplowEvents();
});
it("pre-selects dashboard in embed flow when opened from dashboard sharing modal", () => {
H.visitDashboard(ORDERS_DASHBOARD_ID);
H.openSharingMenu("Embed");
@@ -23,6 +31,8 @@ describe("embed flow pre-selection from sharing modal", () => {
cy.findByRole("link", { name: "Embedded Analytics JS" }).click();
});
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
cy.location("search").should((search) => {
const params = new URLSearchParams(search);
@@ -30,7 +40,6 @@ describe("embed flow pre-selection from sharing modal", () => {
expect(params.get("resource_id")).to.equal(String(ORDERS_DASHBOARD_ID));
});
cy.log("should navigate to embed options step");
getEmbedSidebar().within(() => {
cy.findByText("Behavior").should("be.visible");
cy.findByText("Appearance").should("be.visible");
@@ -41,15 +50,24 @@ describe("embed flow pre-selection from sharing modal", () => {
H.getSimpleEmbedIframeContent()
.findByText("Orders in a dashboard", { timeout: 10_000 })
.should("be.visible");
getEmbedSidebar().findByText("Get Code").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail: "settings=default",
});
});
it("pre-selects question in embed flow when opened from question sharing modal", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
H.openSharingMenu("Embed");
H.getEmbedModalSharingPane().within(() => {
cy.findByRole("link", { name: "Embedded Analytics JS" }).click();
});
H.getEmbedModalSharingPane()
.findByRole("link", { name: "Embedded Analytics JS" })
.click();
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
cy.location("search").should((search) => {
const params = new URLSearchParams(search);
@@ -58,7 +76,6 @@ describe("embed flow pre-selection from sharing modal", () => {
expect(params.get("resource_id")).to.equal(String(ORDERS_QUESTION_ID));
});
cy.log("should navigate to embed options step");
getEmbedSidebar().within(() => {
cy.findByText("Behavior").should("be.visible");
cy.findByText("Appearance").should("be.visible");
@@ -69,5 +86,58 @@ describe("embed flow pre-selection from sharing modal", () => {
H.getSimpleEmbedIframeContent()
.findByText("Orders", { timeout: 10_000 })
.should("be.visible");
getEmbedSidebar().findByText("Get Code").click();
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail: "settings=default",
});
});
it("tracks default resources for pre-selected dashboard", () => {
H.visitDashboard(ORDERS_DASHBOARD_ID);
H.openSharingMenu("Embed");
H.getEmbedModalSharingPane().within(() => {
cy.findByRole("link", { name: "Embedded Analytics JS" }).click();
});
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
cy.log("go back to resource selection step");
getEmbedSidebar().within(() => {
cy.findByText("Back").click();
cy.findByText("Select a dashboard to embed").should("be.visible");
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "default",
});
});
it("tracks default resources for pre-selected question", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
H.openSharingMenu("Embed");
H.getEmbedModalSharingPane().within(() => {
cy.findByRole("link", { name: "Embedded Analytics JS" }).click();
});
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
cy.log("go back to resource selection step");
getEmbedSidebar().within(() => {
cy.findByText("Back").click();
cy.findByText("Select a chart to embed").should("be.visible");
cy.findByText("Next").click();
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_resource_selection_completed",
event_detail: "default",
});
});
});

View File

@@ -1,6 +1,6 @@
import dayjs from "dayjs";
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { resetSnowplow } from "e2e/support/helpers/e2e-snowplow-helpers";
@@ -403,35 +403,51 @@ describe("scenarios > table-editing", () => {
beforeEach(() => {
resetSnowplow();
cy.intercept("GET", `/api/table/${ORDERS_ID}/query_metadata`).as(
"getOrdersTable",
);
H.restore("postgres-writable");
H.resetTestTable({ type: "postgres", table: "scoreboard_actions" });
H.resyncDatabase({
dbId: WRITABLE_DB_ID,
tableName: "scoreboard_actions",
});
H.activateToken("bleeding-edge");
setTableEditingEnabledForDB(WRITABLE_DB_ID);
cy.intercept("GET", "/api/table/*/query_metadata").as("getTableMetadata");
cy.intercept("POST", "api/dataset").as("getTableDataQuery");
cy.intercept("POST", "api/ee/action-v2/execute-bulk").as("executeBulk");
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
H.getTableId({ name: "scoreboard_actions" }).then((tableId) => {
cy.visit(`/browse/databases/${WRITABLE_DB_ID}/tables/${tableId}/edit`);
});
cy.wait("@getOrdersTable");
cy.wait("@getTableMetadata");
});
it("should allow to create a row", () => {
cy.findByTestId("new-record-button").click();
H.modal().within(() => {
cy.findByText("Create a new record").should("be.visible");
cy.findByTestId("Tax-field-input").type("50");
cy.findByTestId("Total-field-input").type("100");
cy.findByTestId("Discount-field-input").type("10");
cy.findByTestId("create-row-form-submit-button").click();
});
H.modal().findByText("Create a new record").should("be.visible");
cy.findByTestId("Team Name-field-input").click();
H.popover().findByRole("textbox").type("New York Bricks");
H.popover()
.findByText(/Add option/)
.click();
cy.findByTestId("Score-field-input").type("987");
cy.findByTestId("Status-field-input").click();
H.popover().findByText("active").click();
cy.findByTestId("create-row-form-submit-button").click();
cy.wait("@executeBulk").then(({ response, request }) => {
expect(request.body.action).to.equal("data-grid.row/create");
expect(response?.body.outputs[0].op).to.equal("created");
expect(response?.body.outputs[0].row.TAX).to.equal(50);
expect(response?.body.outputs[0].row.TOTAL).to.equal(100);
expect(response?.body.outputs[0].row.DISCOUNT).to.equal(10);
expect(response?.body.outputs[0].row.score).to.equal(987);
expect(response?.body.outputs[0].row.status).to.equal("active");
expect(response?.body.outputs[0].row.team_name).to.equal(
"New York Bricks",
);
});
H.undoToast().within(() => {
@@ -439,18 +455,25 @@ describe("scenarios > table-editing", () => {
cy.findByLabelText("close icon").click();
});
H.expectUnstructuredSnowplowEvent({
event: "edit_data_record_modified",
event_detail: "create",
target_id: ORDERS_ID,
triggered_from: "modal",
result: "success",
cy.findByTestId("table-root").within(() => {
cy.findByText("New York Bricks").should("be.visible");
cy.findByText("987").should("be.visible");
});
H.getTableId({ name: "scoreboard_actions" }).then((tableId) => {
H.expectUnstructuredSnowplowEvent({
event: "edit_data_record_modified",
event_detail: "create",
target_id: tableId,
triggered_from: "modal",
result: "success",
});
});
});
it("should allow to delete multiple rows (bulk)", () => {
cy.findAllByTestId("row-select-checkbox").eq(15).click();
cy.findAllByTestId("row-select-checkbox").eq(16).click();
cy.findAllByTestId("row-select-checkbox").eq(1).click();
cy.findAllByTestId("row-select-checkbox").eq(2).click();
cy.log("should not show edit icon when rows are selected");
cy.findByTestId("row-edit-icon").should("not.exist");

View File

@@ -1282,3 +1282,42 @@ describe("issue 54271", () => {
H.assertQueryBuilderRowCount(1076);
});
});
describe("issue 63671", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.createQuestion(
{
query: {
"source-table": PRODUCTS_ID,
aggregation: [["count"]],
breakout: [
[
"field",
PRODUCTS.CREATED_AT,
{
"temporal-unit": "year",
},
],
],
filter: [
"between",
["field", PRODUCTS.CREATED_AT, null],
"2025-01-01",
"2025-12-31",
],
},
display: "bar",
},
{ visitQuestion: true },
);
});
it("should not show an extra value on bar charts when there is only value on the x axis (metabase#63671)", () => {
cy.findByTestId("query-visualization-root")
.findByText("2025")
.should("have.length", 1);
});
});

View File

@@ -639,8 +639,7 @@ describe("issue 37726", () => {
});
});
// unskip once metabase#42049 is addressed
describe("issue 42049", { tags: "@skip" }, () => {
describe("issue 42049", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
@@ -703,11 +702,11 @@ describe("issue 42049", { tags: "@skip" }, () => {
cy.get("@headerCells").eq(1).should("have.text", "Created At");
cy.get("@headerCells").eq(2).should("have.text", "Quantity");
cy.findByRole("button", { name: "Filter" }).click();
cy.findByTestId("question-filter-header").click();
cy.findByRole("dialog").within(() => {
cy.findByRole("button", { name: "Last month" }).click();
cy.findByRole("button", { name: "Apply filters" }).click();
H.popover().within(() => {
cy.findByText("Created At").click();
cy.button("Previous month").click();
});
cy.wait("@cardQuery");

View File

@@ -56,4 +56,5 @@
(defmethod advanced-config.file.i/initialize-section! :users
[_section-name users]
(doseq [user users]
(init-from-config-file! user)))
;; we're lower-casing emails in :model/User, so we should do the same here
(init-from-config-file! (update user :email u/lower-case-en))))

View File

@@ -20,6 +20,7 @@
[metabase-enterprise.content-verification.api.routes]
[metabase-enterprise.database-replication.api :as database-replication.api]
[metabase-enterprise.database-routing.api]
[metabase-enterprise.dependencies.api]
[metabase-enterprise.documents.api]
[metabase-enterprise.email.api]
[metabase-enterprise.embedding-hub.api]
@@ -52,6 +53,7 @@
:ai-entity-analysis (deferred-tru "AI Entity Analysis")
:collection-cleanup (deferred-tru "Collection Cleanup")
:content-translation (deferred-tru "Content translation")
:dependencies (deferred-tru "Dependency Tracking")
:documents (deferred-tru "Documents")
:embedding (deferred-tru "Embedding")
:etl-connections (deferred-tru "ETL Connections")
@@ -104,6 +106,7 @@
(premium-handler :etl-connections)
(premium-handler :etl-connections-pg))
"/database-routing" (premium-handler metabase-enterprise.database-routing.api/routes :database-routing)
"/dependencies" (premium-handler metabase-enterprise.dependencies.api/routes :dependencies)
"/document" (premium-handler metabase-enterprise.documents.api/routes :documents)
"/email" (premium-handler metabase-enterprise.email.api/routes :cloud-custom-smtp)
"/embedding-hub" (premium-handler metabase-enterprise.embedding-hub.api/routes :embedding)

View File

@@ -12,6 +12,7 @@
[metabase-enterprise.cache.init]
[metabase-enterprise.comments.init]
[metabase-enterprise.database-replication.init]
[metabase-enterprise.dependencies.init]
[metabase-enterprise.documents.init]
[metabase-enterprise.gsheets.init]
[metabase-enterprise.llm.init]

View File

@@ -0,0 +1,124 @@
(ns metabase-enterprise.dependencies.api
(:require
[medley.core :as m]
[metabase-enterprise.dependencies.core :as dependencies]
[metabase.analyze.core :as analyze]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.collections.models.collection.root :as collection.root]
[metabase.lib-be.metadata.jvm :as lib-be.metadata.jvm]
[metabase.lib.metadata :as lib.metadata]
[metabase.models.interface :as mi]
[metabase.native-query-snippets.core :as native-query-snippets]
[metabase.queries.schema :as queries.schema]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(mr/def ::card-body
[:map
[:id {:optional false} ms/PositiveInt]
[:dataset_query {:optional true} [:maybe ms/Map]]
[:type {:optional true} [:maybe ::queries.schema/card-type]]
[:result_metadata {:optional true} [:maybe analyze/ResultsMetadata]]])
(defn- broken-cards-response
[{:keys [card transform]}]
(let [broken-card-ids (keys card)
broken-cards (when (seq broken-card-ids)
(-> (t2/select :model/Card :id [:in broken-card-ids])
(t2/hydrate [:collection :effective_ancestors] :dashboard)))
broken-transform-ids (keys transform)
broken-transforms (when (seq broken-transform-ids)
(t2/select :model/Transform :id [:in broken-transform-ids]))]
{:success (and (empty? broken-card-ids)
(empty? broken-transform-ids))
:bad_cards (into [] (comp (filter (fn [card]
(if (mi/can-read? card)
card
(do (log/warnf "Eliding broken card %d - not readable by the user" (:id card))
nil))))
(map (fn [card]
(-> card
collection.root/hydrate-root-collection
(update :dashboard #(some-> % (select-keys [:id :name])))))))
broken-cards)
:bad_transforms (into [] broken-transforms)}))
(api.macros/defendpoint :post "/check_card"
"Check a proposed edit to a card, and return the card IDs for those cards this edit will break."
[_route-params
_query-params
body :- ::card-body]
(let [database-id (-> body :dataset_query :database)
base-provider (lib-be.metadata.jvm/application-database-metadata-provider database-id)
original (lib.metadata/card base-provider (:id body))
card (-> original
(assoc :dataset-query (:dataset_query body)
:type (:type body (:type original)))
;; Remove the old `:result-metadata` from the card, it's likely wrong now.
(dissoc :result-metadata)
;; But if the request includes `:result_metadata`, use that. It may be from a native card
;; that's been run before saving the card.
(cond-> #_card
(:result_metadata body) (assoc :result-metadata (:result_metadata body))))
edits {:card [card]}
breakages (dependencies/errors-from-proposed-edits base-provider edits)]
(broken-cards-response breakages)))
(mr/def ::transform-body
[:map
[:id {:optional false} ms/PositiveInt]
[:name {:optional true} :string]
[:source {:optional true} [:maybe ms/Map]]
[:target {:optional true} [:maybe ms/Map]]])
(api.macros/defendpoint :post "/check_transform"
"Check a proposed edit to a transform, and return the card, transform, etc. IDs for things that will break."
[_route-params
_query-params
{:keys [id source target] :as _body} :- ::transform-body]
(if (= (keyword (:type source))
:query)
(let [database-id (-> source :query :database)
base-provider (lib-be.metadata.jvm/application-database-metadata-provider database-id)
original (lib.metadata/transform base-provider id)
transform (-> original
(cond-> #_transform source (assoc :source source))
(cond-> #_transform target (assoc :target target)))
edits {:transform [transform]}
breakages (dependencies/errors-from-proposed-edits base-provider edits)]
(broken-cards-response breakages))
;; if this isn't a sql query, just claim it works
{:success true}))
(api.macros/defendpoint :post "/check_snippet"
"Check a proposed edit to a native snippet, and return the cards, etc. which will be broken."
[_route-params
_query-params
{:keys [id content], snippet-name :name}
:- [:map
[:id {:optional false} ms/PositiveInt]
[:name {:optional true} native-query-snippets/NativeQuerySnippetName]
[:content {:optional true} :string]]]
(let [original (t2/select-one :model/NativeQuerySnippet id)
_ (when (and snippet-name
(not= snippet-name (:name original))
(t2/exists? :model/NativeQuerySnippet :name snippet-name))
(throw (ex-info (tru "A snippet with that name already exists. Please pick a different name.")
{:status-code 400})))
snippet (cond-> (m/assoc-some original
:lib/type :metadata/native-query-snippet
:name snippet-name
:content content)
content native-query-snippets/add-template-tags)
breakages (dependencies/errors-from-proposed-edits {:snippet [snippet]})]
(broken-cards-response breakages)))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/dependencies` routes."
(handlers/routes
(api.macros/ns-handler *ns* +auth)))

View File

@@ -0,0 +1,66 @@
(ns metabase-enterprise.dependencies.calculation
(:require
[metabase-enterprise.dependencies.native-validation :as deps.native]
[metabase.lib-be.metadata.jvm :as lib-be.metadata.jvm]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.lib.util :as lib.util]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(defn- upstream-deps:mbql-query [legacy-query]
(lib.util/source-tables-and-cards [legacy-query]))
(defn- upstream-deps:native-query [metadata-provider query]
(let [engine (:engine (lib.metadata/database metadata-provider))
deps (deps.native/native-query-deps engine metadata-provider query)]
;; The deps are in #{{:table 7} ...} form and need conversion to ::upstream-deps form.
(u/group-by ffirst (comp second first) conj #{} deps)))
(mr/def ::upstream-deps
[:map
[:card {:optional true} [:set ::lib.schema.id/card]]
[:table {:optional true} [:set ::lib.schema.id/table]]
[:snippet {:optional true} [:set ::lib.schema.id/snippet]]
[:transform {:optional true} [:set ::lib.schema.id/transform]]])
(defn- upstream-deps:query [metadata-provider legacy-query]
(case (:type legacy-query)
(:query "query") (upstream-deps:mbql-query legacy-query)
(:native "native") (upstream-deps:native-query metadata-provider legacy-query)
(throw (ex-info "Unhandled kind of query" {:query legacy-query}))))
(mu/defn upstream-deps:card :- ::upstream-deps
"Given a Toucan `:model/Card`, return its upstream dependencies as a map from the kind to a set of IDs."
([card] (upstream-deps:card (lib-be.metadata.jvm/application-database-metadata-provider (:database_id card))
card))
([metadata-provider :- ::lib.schema.metadata/metadata-provider
{query :dataset_query :as _toucan-card}]
(upstream-deps:query metadata-provider query)))
(mu/defn upstream-deps:transform :- ::upstream-deps
"Given a Transform (in Toucan form), return its upstream dependencies."
([transform] (upstream-deps:transform nil transform))
([metadata-provider :- [:maybe ::lib.schema.metadata/metadata-provider]
{{:keys [query type]} :source :as transform}]
(if (#{"query" :query} type)
(upstream-deps:query (or metadata-provider
(lib-be.metadata.jvm/application-database-metadata-provider (:database query)))
query)
(do (log/warnf "Don't know how to analyze the deps of Transform %d with source type '%s'" (:id transform) type)
{}))))
(mu/defn upstream-deps:snippet :- ::upstream-deps
"Given a native query snippet, return its upstream dependencies in the usual `{entity-type #{1 2 3}}` format."
[{:keys [template_tags] :as _snippet}]
(let [type->id-key {:card :card-id, :snippet :snippet-id}
dependencies (keep (fn [tag]
(let [entity-type (:type tag)]
(when-let [id-key (type->id-key entity-type)]
(when-let [entity-id (id-key tag)]
[entity-type entity-id]))))
(vals template_tags))]
(u/group-by first second conj #{} dependencies)))

View File

@@ -0,0 +1,205 @@
(ns metabase-enterprise.dependencies.core
"API namespace for the `metabase-enterprise.dependencies` module.
Call [[errors-from-proposed-edits]] to find out what things will break downstream of a set of new/updated entities."
(:require
[metabase-enterprise.dependencies.metadata-provider :as deps.provider]
[metabase-enterprise.dependencies.models.dependency :as deps.graph]
[metabase-enterprise.dependencies.native-validation :as deps.native]
[metabase.graph.core :as graph]
[metabase.lib-be.metadata.jvm :as lib-be.metadata.jvm]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[toucan2.core :as t2]))
(mr/def ::entity-type
[:enum :card :transform :snippet :table])
(mr/def ::updates-map
;; TODO: Make this more specific.
[:map-of ::entity-type [:sequential [:map [:id {:optional true} :int]]]])
(mu/defn- metadata-provider :- ::lib.schema.metadata/metadata-provider
"Constructs a `MetadataProvider` with some pending edits applied.
The edits are *transitive*! Since a card's output columns can depend on its (possibly updated) inputs, all dependents
of editing things are considered edited too.
Note that if `:result-metadata` is present on an updated Card, it **will be used!** The only case where that is
actually useful is with a native query which has been executed, so the caller has driver metadata to give us. Any
old, pre-update `:result-metadata` should be dropped from any other cards in `updated-entities`."
([base-provider :- ::lib.schema.metadata/metadata-provider
updated-entities :- ::updates-map
& {:keys [graph dependents]}]
(let [dependents (or dependents (deps.graph/transitive-dependents graph updated-entities))]
(deps.provider/override-metadata-provider base-provider updated-entities dependents))))
(defmulti check-query-inner
"Given a `MetadataProvider` and a lib query, find any bad refs in that query."
{:arglists '([metadata-provider driver query])}
(fn [_mp _driver query]
(-> (lib/query-stage query 0)
:lib/type)))
(defmethod check-query-inner :mbql.stage/mbql
[_mp _driver query]
(lib/find-bad-refs query))
(defmethod check-query-inner :mbql.stage/native
[mp driver query]
(deps.native/validate-native-query driver mp query))
(defn- check-query
"Given a `MetadataProvider` and a query, find any bad refs in that query."
[mp driver query]
(check-query-inner mp driver (lib/query mp query)))
(defmulti check-entity
"Given a `MetadataProvider`, and entity type, and an entity id, find any bad refs in that entity."
{:arglists '([metadata-provider entity-type entity-id])}
(fn [_mp entity-type _entity-id]
entity-type))
(defmethod check-entity :default
[_mp _entity-type _entity-id]
nil)
(defmethod check-entity :card
[mp _entity-type entity-id]
(let [query (:dataset-query (lib.metadata/card mp entity-id))
driver (:engine (lib.metadata/database mp))]
(check-query mp driver query)))
(defmethod check-entity :transform
[mp _entity-type entity-id]
(let [{{target-schema :schema target-name :name} :target
{:keys [query]} :source
:as _transform} (lib.metadata/transform mp entity-id)
driver (:engine (lib.metadata/database mp))
output-table (some #(when (and (= (:schema %) target-schema)
(= (:name %) target-name))
%)
(lib.metadata/tables mp))
output-fields (lib.metadata/active-fields mp (:id output-table))
{:keys [duplicate-fields]} (reduce (fn [{:keys [seen duplicate-fields]}
{name :name :as field}]
(if (seen name)
{:seen seen
:duplicate-fields (conj duplicate-fields field)}
{:seen (conj seen name)
:duplicate-fields duplicate-fields}))
{:seen #{}}
output-fields)]
(into duplicate-fields (check-query mp driver query))))
(mu/defn- check-query-soundness ;; :- [:map-of ::lib.schema.id/card [:sequential ::lib.schema.mbql-clause/clause]]
"Given a `MetadataProvider` as returned by [[metadata-provider]], scan all its updated entities and their dependents
to check that everything is still sound.
In particular, we're looking for bad clauses with refs to columns which no longer exist, since those queries fail
to compile.
May return false positives: if a card already has a bad ref in it, then it will be returned even if that bad ref has
nothing to do with the updates in the `provider`.
Returns a map `{:card {card-id [bad-ref ...]}, :transform {...}}`. It will be empty, if there are no bad refs
detected."
[provider :- ::lib.schema.metadata/metadata-provider]
(let [overrides (deps.provider/all-overrides provider)
errors (volatile! {})]
(doseq [[entity-type ids] overrides
id ids
:let [bad-refs (check-entity provider entity-type id)]]
(when (seq bad-refs)
(vswap! errors assoc-in [entity-type id] bad-refs)))
@errors))
(defn- group-by-db [deps]
(let [by-db (volatile! {})]
(when (seq (:card deps))
(doseq [[db-id card-ids] (->> (t2/select [:model/Card :id :database_id :card_schema] :id [:in (:card deps)])
(u/group-by :database_id :id conj #{}))]
(vswap! by-db assoc-in [db-id :card] card-ids)))
(when (seq (:table deps))
(doseq [[db-id table-ids] (->> (t2/select [:model/Table :id :db_id] :id [:in (:table deps)])
(u/group-by :db_id :id conj #{}))]
(vswap! by-db assoc-in [db-id :table] table-ids)))
(when (seq (:transform deps))
(doseq [[db-id transform-ids] (->> (t2/select [:model/Transform :id :source] :id [:in (:transform deps)])
(u/group-by #(get-in % [:source :query :database]) :id conj #{}))]
(if db-id
(vswap! by-db assoc-in [db-id :transform] transform-ids)
(log/warnf "Unable to infer database from transforms %s" transform-ids))))
(when (seq (:snippet deps))
;; Copy any snippet deps to each database, since they span them all.
(vswap! by-db update-vals #(assoc % :snippet (:snippet deps))))
@by-db))
;; TODO: (Braden 09/22/2025) More precise schemas for the errors this function returns. Currently they're pretty
;; opaque, since the consumers of this function really care about "working/broken" and not the details of what's wrong
;; with any particular card.
(mu/defn errors-from-proposed-edits :- [:map-of ::entity-type [:map-of :int [:or :boolean [:sequential :any]]]]
"Given a regular `MetadataProvider`, and a map of entity types (`:card`, `:transform`, `:snippet`) to lists of
updated entities, this returns a map of `{entity-type {entity-id [bad-ref ...]}}`.
The 1-arity groups all the dependents by which Database they are part of, and runs the analysis for each of them.
The output is a map: `{entity-type {id [errors...]}}`; an empty map is returned when there are no errors
detected."
([edits :- ::updates-map]
(let [all-deps (deps.graph/transitive-dependents edits)
by-db (group-by-db all-deps)]
(reduce (fn [errors [db-id deps]]
(-> (lib-be.metadata.jvm/application-database-metadata-provider db-id)
(metadata-provider edits :dependents deps)
check-query-soundness
(merge errors)))
{} by-db)))
([base-provider :- ::lib.schema.metadata/metadata-provider
edits :- ::updates-map]
(errors-from-proposed-edits base-provider nil edits))
([base-provider :- ::lib.schema.metadata/metadata-provider
graph :- [:maybe ::graph/graph]
edits :- ::updates-map]
(-> base-provider
(metadata-provider edits :graph graph)
check-query-soundness)))
#_{:clj-kondo/ignore [:unresolved-namespace]}
(comment
;; This should work on any fresh-ish Metabase instance; these are the built-in example questions.
(let [base-mp (lib-be.metadata.jvm/application-database-metadata-provider 39)
transform (lib.metadata/transform base-mp 1)
transform (-> transform
(update-in [:source :query :query :expressions]
;; Replacing the expression Age with Duration - this breaks downstream uses!
update-keys (constantly "Fair Die"))
#_(dissoc :result-metadata))
mp (metadata-provider base-mp {:transform [transform]})]
#_(deps.provider/all-overrides mp)
(check-query-soundness mp))
;; Checking that we can properly group everything by database. Surprisingly fast even fetching everything.
(let [deps {:card (t2/select-fn-set :id :model/Card)
:transform (t2/select-fn-set :id :model/Transform)
:table (t2/select-fn-set :id :model/Table)}]
(group-by-db deps))
(t2/select-fn-vec (juxt :id :name :active :table_id) :model/Field :id [:in [1065 1066 1067 1068 1069]])
(t2/select-one :model/Card :id 124)
(deps.graph/transitive-dependents {:transform [{:id 1}]})
(deps.graph/transitive-dependents {:table [{:id 155}]})
(metabase.premium-features.core/token-features)
(let [card (t2/select-one :model/Card :id 121)
mp (lib-be.metadata.jvm/application-database-metadata-provider (:database_id card))]
(upstream-deps:card mp card)))

View File

@@ -0,0 +1,4 @@
(ns metabase-enterprise.dependencies.init
(:require
[metabase-enterprise.dependencies.models.dependency]
[metabase-enterprise.dependencies.task.backfill]))

View File

@@ -0,0 +1,271 @@
(ns metabase-enterprise.dependencies.metadata-provider
"A `MetadataProvider` wrapper that understands how to update entities, and to lazily compute the new metadata for
updated entities on demand. This is useful when analyzing a tree of dependents, which might depend on each other in
complex ways.
Just to illustrate, imagine I change a card A which is used by a transform B whose output table C is used by a card
D. If I remove a column from card A, and transform B selects all columns, that field needs to disappear from C
before we can correctly analyze card D.
By representing the updated entities as lazy `delay`s, this `MetadataProvider` can support analysis of the
downstream entities in any order."
(:require
[medley.core :as m]
[metabase-enterprise.dependencies.native-validation :as deps.native]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.protocols :as lib.metadata.protocols]
[metabase.util :as u]
[metabase.util.log :as log]
[pretty.core :as pretty]))
(set! *warn-on-reflection* true)
(defn- metadatas [delegate overrides {metadata-type :lib/type
search-name :name
:keys [id card-id table-id]
:as metadata-spec}]
(let [type-overrides (get overrides metadata-type)
metadata-keys (set (or id search-name))]
(log/tracef "OverridingMetadataProvider request for %s" metadata-spec)
(cond
;; For tables, cards, snippets, transforms, and columns, return overrides if present and looking for specific
;; keys and delegate if not.
(and (#{:metadata/table :metadata/card :metadata/transform :metadata/native-query-snippet :metadata/column} metadata-type)
(seq metadata-keys))
(let [overrides (if (seq id)
(select-keys type-overrides id)
(into {} (keep #(when-let [x (deref %)]
(when (search-name (:name x))
[(:name x) %])))
(vals type-overrides)))
override-key (if (seq id) :id :name)
overridden-keys (set (keys overrides))
missing-keys (remove overridden-keys metadata-keys)]
(log/tracef "Overridden keys %s; missing keys %s" (sort overridden-keys) (sort missing-keys))
(cond-> (map deref (vals overrides))
(seq missing-keys)
(concat (lib.metadata.protocols/metadatas delegate {:lib/type metadata-type
override-key (set missing-keys)}))))
;; if fetching all tables or transforms, get every override and everything from the delegate and then dedupe
(and (empty? metadata-keys)
(#{:metadata/table :metadata/transform} metadata-type))
(as-> (vals type-overrides) entities
(into [] (map force) entities)
(into entities (lib.metadata.protocols/metadatas delegate metadata-spec))
(m/distinct-by :id entities))
;; For columns, return overrides if they're present.
(= metadata-type :metadata/column)
(if-let [overridden-columns (cond
card-id (get-in overrides [::card-columns card-id])
table-id (get-in overrides [::table-columns table-id]))]
@overridden-columns
(lib.metadata.protocols/metadatas delegate metadata-spec))
:else (throw (ex-info "Unknown :lib/metadata type for OverridingMetadataProvider" metadata-spec)))))
(declare all-overrides setup-transform! setup-transforms!)
(deftype OverridingMetadataProvider [delegate *overrides]
lib.metadata.protocols/MetadataProvider
(database [_this] (lib.metadata.protocols/database delegate))
(metadatas [this metadata-spec]
(setup-transforms! this)
(metadatas delegate @*overrides metadata-spec))
(setting [_this setting-key]
(lib.metadata.protocols/setting delegate setting-key))
;; Cached things are direct delegation.
lib.metadata.protocols/CachedMetadataProvider
(cached-metadatas [_this metadata-type metadata-ids]
(lib.metadata.protocols/cached-metadatas delegate metadata-type metadata-ids))
(store-metadata! [_this a-metadata]
(lib.metadata.protocols/store-metadata! delegate a-metadata))
(cached-value [_this k not-found]
(lib.metadata.protocols/cached-value delegate k not-found))
(cache-value! [_this k v]
(lib.metadata.protocols/cache-value! delegate k v))
(has-cache? [_this]
(lib.metadata.protocols/has-cache? delegate))
pretty/PrettyPrintable
(pretty [this]
(list `overriding-metadata-provider (all-overrides this) delegate)))
(defmulti add-override
"Given an `OverridingMetadataProvider`, an `entity-type` and that entity, add this entity as an override."
{:arglists '([metadata-provider entity-type id updates-or-nil])}
(fn [_mp entity-type _id _updates]
entity-type))
(defn- inner-mp [^OverridingMetadataProvider mp]
(.delegate mp))
(defn- with-overrides [^OverridingMetadataProvider mp kvs]
(swap! (.*overrides mp)
#(reduce (fn [m [ks v]]
(assoc-in m ks v))
% kvs)))
(defn- get-returned-columns [mp queryable]
(let [query (lib/query mp queryable)]
(if (= (:lib/type (lib/query-stage query 0))
:mbql.stage/mbql)
(lib/returned-columns query)
(deps.native/native-result-metadata (:engine (lib.metadata/database mp))
mp query))))
(defmethod add-override :card [^OverridingMetadataProvider mp _entity-type id updates]
(with-overrides mp
;; If the `updates` contain `:result-metadata`, we want to use that. However, any `:result-metadata` from the
;; inner-mp should be ignored.
{[:metadata/card id] (delay (merge (when id
(-> (inner-mp mp)
(lib.metadata/card id)
(dissoc :result-metadata)))
updates))
;; This uses the outer OMP and so the overrides are visible!
[::card-columns id] (delay (get-returned-columns mp (lib.metadata/card mp id)))}))
(defonce ^:private last-fake-id (atom 2000000000))
(defn- fake-id []
(swap! last-fake-id inc))
(defn- setup-transform!
[^OverridingMetadataProvider mp s+n->table {:keys [source target] :as _transform}]
;; See if the output table exists in the underlying MetadataProvider.
;; - If the table doesn't exist, invent an ID for it.
;; - Always add an override for the table, even if it already existed.
;; - Compare the transform's output columns to the fields that exist in the metadata.
;; - For any newly added fields, invent an ID and add an override for the field.
;; - For any removed fields that used to exist, add an override for their ID that is nil!
;; - Add an internal [::table-columns table-id] with the list of output columns.
;; Note that this assumes that all other overrides are already loaded!
(let [existing-table (get s+n->table [(:schema target) (:name target)])
output-table (or existing-table
(merge (select-keys target [:schema :name])
{:lib/type :metadata/table
:id (fake-id)
:db-id (:id (lib.metadata/database (inner-mp mp)))
:display-name (:name target)
:visibility-type nil}))
existing-cols (when existing-table
(lib/returned-columns (lib/query (inner-mp mp) existing-table)))
output-cols (delay
;; Note that this will analyze the query with any upstream changes included!
(let [new-cols (get-returned-columns mp (:query source))
by-name (m/index-by :lib/desired-column-alias existing-cols)]
(into [] (for [col new-cols
:let [old-col (by-name (:lib/desired-column-alias col))]]
(merge (select-keys col [:display-name
:base-type :effective-type :semantic-type])
{:name (:lib/desired-column-alias col)
:lib/type :metadata/column
:id (or (:id old-col) (fake-id))
:table-id (:id output-table)
:active true})))))
outputs-by-id (delay (m/index-by :id @output-cols))]
;; NOTE: Any newly added output columns can't be looked up directly by ID, with this version.
;; The `output-cols` code above can be adapted to add them to the `*overrides` atom, but YAGNI for now.
(with-overrides mp
(into {[:metadata/table (:id output-table)] (delay output-table)
[::table-columns (:id output-table)] output-cols}
;; For each pre-existing output column, we need to add an override for a direct read by ID.
;; If the column does not exist in the output, return the original with :active false
(map (fn [existing-col]
[[:metadata/column (:id existing-col)]
(delay (or (get @outputs-by-id (:id existing-col))
(assoc existing-col :active false)))]))
existing-cols))))
(defn- setup-transforms! [^OverridingMetadataProvider mp]
(locking mp
(when-not (-> (.*overrides mp) deref ::setup-complete)
(swap! (.*overrides mp) assoc ::setup-complete true)
(let [;; Fetching all the pre-existing tables.
;; TODO: Once Cam's new by-name logic in the `MetadataProvider` lands, we can use that here.
tables (lib.metadata/tables (inner-mp mp))
s+n->table (m/index-by (juxt :schema :name) tables)]
(doseq [transform (-> (.*overrides mp) deref :metadata/transform vals)]
(setup-transform! mp s+n->table (deref transform)))))))
(defmethod add-override :transform [^OverridingMetadataProvider mp _entity-type id updates]
;; Transforms are complicated:
;; 1. Treated as a card on the input side.
;; 2. The returned-columns of that card become the columns of the output table.
;; 3. The output table is constructed from the transform.
;; 4. Its fields are adapted from the transform's input card.
;; All of these can be wrapped in delays and therefore computed lazily on demand.
;;
;; The trouble is that a transform does not know the output table's ID, nor its field IDs.
;; A modified transform might produce a new table, or have new fields or removed fields.
;; The OverridingMetadataProvider has to account for all of this when it returns tables or fields by ID, or gets
;; the column list for a table.
;;
;; This is achieved by putting `[:metadata/transform id]` into overrides for all transforms, and then one-time
;; logic that runs before the first use of the OverridingMetadataProvider as a MetadataProvider will walk all the
;; transforms and make sure their tables etc. are overridden properly.
(let [transform (merge (when id
(lib.metadata/transform (inner-mp mp) id))
updates)]
(with-overrides mp
{;; The transform itself, merged with the incoming updates.
[:metadata/transform id] (delay transform)
#_#_[::transform-columns id] (delay (->> (lib.metadata/transform mp id)
:source
:query
(lib/query mp)
lib/returned-columns))})))
(defmethod add-override :table [^OverridingMetadataProvider mp _entity-type id updates]
(when (seq updates)
(throw (ex-info "Updating a table directly is not supported" {:table-id id, :edited updates})))
(with-overrides mp
{[:metadata/table id] (delay (log/warnf "Can't happen: Table %d dep should be replaced by its transform" id)
(lib.metadata/table (inner-mp mp) id))
[::table-columns id] (delay
(log/warnf "Can't happen: Table %d ::table-columns should be replaced by its transform" id)
(lib.metadata/fields (inner-mp mp) id))}))
(defmethod add-override :snippet [^OverridingMetadataProvider mp _entity-type id updates]
(with-overrides mp
{[:metadata/native-query-snippet id] (delay (merge (when id
(lib.metadata/native-query-snippet (inner-mp mp) id))
(u/normalize-map updates)))}))
(defn all-overrides
"Returns all the overrides by ID, in the same form as the map input to [[with-deps]]:
`{:card [1 2 3], :transform [45 99]}."
[^OverridingMetadataProvider override-metadata-provider]
(-> (.*overrides override-metadata-provider)
deref
(dissoc ::setup-complete)
(update-vals keys)
(update-keys {:metadata/card :card
:metadata/transform :transform
:metadata/native-query-snippet :snippet})
(select-keys [:card :transform :snippet])))
(defn override-metadata-provider
"Given an underlying `MetadataProvider`, wraps it to support in-memory overrides of cards, fields, etc.
Important note: all overrides must be added first, before this is used as a `MetadataProvider`.
The easiest way to do that is to call the 3-arity, which provides the overrides up front."
([metadata-provider]
(->OverridingMetadataProvider metadata-provider (atom {})))
([metadata-provider updated-entities dependent-ids]
(let [^OverridingMetadataProvider omp (override-metadata-provider metadata-provider)]
(doseq [[entity-type updates] updated-entities
updated-entity updates]
(add-override omp entity-type (:id updated-entity) updated-entity))
(doseq [[entity-type dependents] dependent-ids
:let [updated (into #{} (map :id) (get updated-entities entity-type))]
id dependents
:when (not (updated id))]
(add-override omp entity-type id nil))
omp)))

View File

@@ -0,0 +1,212 @@
(ns metabase-enterprise.dependencies.models.dependency
(:require
[clojure.set :as set]
[metabase-enterprise.dependencies.calculation :as deps.calculation]
[metabase.events.core :as events]
[metabase.graph.core :as graph]
[metabase.models.interface :as mi]
[metabase.premium-features.core :as premium-features]
[metabase.util :as u]
[methodical.core :as methodical]
[potemkin :as p]
[toucan2.core :as t2]))
(def current-dependency-analysis-version
"Current version of the dependency analysis logic.
This should be incremented when the dependency analysis logic changes."
1)
(methodical/defmethod t2/table-name :model/Dependency [_model] :dependency)
(derive :model/Dependency :metabase/model)
(t2/deftransforms :model/Dependency
{:from_entity_type mi/transform-keyword
:to_entity_type mi/transform-keyword})
(defn- deps-children [src-type src-id dst-type dst-id key-seq]
;; Group all keys with the same type together, so we make O(types) indexed [[t2/select]] calls, not O(n).
(transduce (map (fn [[entity-type entity-keys]]
(let [deps (t2/select :model/Dependency
src-type entity-type
src-id [:in entity-keys])]
(u/group-by (juxt src-type src-id)
(juxt dst-type dst-id)
conj #{}
deps))))
merge {}
(u/group-by first second key-seq)))
(defn- key-dependents
"Get the dependent entity keys for the entity keys in `entity-keys`.
Entity keys are [entity-type, entity-id] pairs. See [[entity-type->model]]."
[key-seq]
(deps-children :to_entity_type :to_entity_id :from_entity_type :from_entity_id key-seq))
(p/deftype+ DependencyGraph [children-fn]
graph/Graph
(children-of [_this key-seq]
(children-fn key-seq)))
;; NOTE: We can easily construct a graph of upstream dependencies too, if it's useful.
(defn- graph-dependents []
(->DependencyGraph key-dependents))
(defn transitive-dependents
"Given a map of updated entities `{entity-type [{:id 1, ...} ...]}`, return a map of its transitive dependents
as `{entity-type #{4 5 6}}` - that is, a map from downstream entity type to a set of IDs.
Uses the provided `graph`, or defaults to the `:model/Dependency` table in AppDB.
The inputs must be maps containing `:id`; anything without an `:id` is skipped. They could be Toucan entities,
`MetadataProvider` entities, user input, etc.
**Excludes** the input entities from the list of dependents!"
([updated-entities] (transitive-dependents nil updated-entities))
([graph updated-entities]
(let [graph (or graph (graph-dependents))
starters (for [[entity-type updates] updated-entities
entity updates
:when (:id entity)]
[entity-type (:id entity)])]
(->> (graph/transitive graph starters) ; This returns a flat list.
(u/group-by first second conj #{})))))
(defn replace-dependencies
"Replace the dependencies of the entity of type `entity-type` with id `entity-id` with
the ones specified in `dependencies-by-type`. "
[entity-type entity-id dependencies-by-type]
(let [current-dependencies (t2/select [:model/Dependency :id :to_entity_type :to_entity_id]
:from_entity_type entity-type
:from_entity_id entity-id)
to-remove (keep (fn [{:keys [id to_entity_type to_entity_id]}]
(when-not (get-in dependencies-by-type [to_entity_type to_entity_id])
id))
current-dependencies)
current-by-type (-> (group-by :to_entity_type current-dependencies)
(update-vals #(into #{} (map :to_entity_id) %)))
to-add (for [[to-entity-type ids] dependencies-by-type
to-entity-id (set/difference ids (current-by-type to-entity-type))]
{:from_entity_type entity-type
:from_entity_id entity-id
:to_entity_type to-entity-type
:to_entity_id to-entity-id})]
(t2/with-transaction [_conn]
(when (seq to-remove)
(t2/delete! :model/Dependency :id [:in to-remove]))
(when (seq to-add)
(t2/insert! :model/Dependency to-add)))))
;; ## Maintaining the dependency graph
;; The below listens for inserts, updates and deletes of cards, snippets and transforms in order to keep the
;; dependency graph up to date. Transform *runs* are also a trigger, since the transform's output table may be created
;; or changed at that point.
;; ### Cards
(derive ::card-deps :metabase/event)
(derive :event/card-create ::card-deps)
(derive :event/card-update ::card-deps)
(methodical/defmethod events/publish-event! ::card-deps
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(t2/with-transaction [_conn]
(replace-dependencies :card (:id object) (deps.calculation/upstream-deps:card object))
(when (not= (:dependency_analysis_version object) current-dependency-analysis-version)
(t2/update! :model/Card (:id object)
{:dependency_analysis_version current-dependency-analysis-version})))))
(derive ::card-delete :metabase/event)
(derive :event/card-delete ::card-delete)
(methodical/defmethod events/publish-event! ::card-delete
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(t2/delete! :model/Dependency :from_entity_type :card :from_entity_id (:id object))))
;; ### Snippets
(derive ::snippet-deps :metabase/event)
(derive :event/snippet-create ::snippet-deps)
(derive :event/snippet-update ::snippet-deps)
(methodical/defmethod events/publish-event! ::snippet-deps
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(t2/with-transaction [_conn]
(replace-dependencies :snippet (:id object) (deps.calculation/upstream-deps:snippet object))
(when (not= (:dependency_analysis_version object) current-dependency-analysis-version)
(t2/update! :model/NativeQuerySnippet (:id object)
{:dependency_analysis_version current-dependency-analysis-version})))))
(derive ::snippet-delete :metabase/event)
(derive :event/snippet-delete ::snippet-delete)
(methodical/defmethod events/publish-event! ::snippet-delete
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(t2/delete! :model/Dependency :from_entity_type :snippet :from_entity_id (:id object))))
;; ### Transforms
(derive ::transform-deps :metabase/event)
(derive :event/create-transform ::transform-deps)
(derive :event/update-transform ::transform-deps)
;; On *saving* a transform, the upstream deps of its query are computed and saved.
(defn- drop-outdated-target-dep! [{:keys [id source target] :as _transform}]
(let [db-id (some-> source :query :database)
downstream-table-ids (t2/select-fn-set :from_entity_id :model/Dependency
:from_entity_type :table
:to_entity_type :transform
:to_entity_id id)
downstream-tables (when (seq downstream-table-ids)
(t2/select :model/Table :id [:in downstream-table-ids]))
outdated-tables (remove (fn [table]
(and (= (:schema table) (:schema target))
(= (:name table) (:name target))
(or (not db-id)
(= db-id (:db_id table)))))
downstream-tables)
not-found-table-ids (remove (into #{} (map :id) downstream-tables)
downstream-table-ids)]
(when-let [outdated-downstream-table-ids (seq (into (set not-found-table-ids)
(map :id) outdated-tables))]
(t2/delete! :model/Dependency
:from_entity_type :table
:from_entity_id [:in outdated-downstream-table-ids]
:to_entity_type :transform
:to_entity_id id))))
(methodical/defmethod events/publish-event! ::transform-deps
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(t2/with-transaction [_conn]
(replace-dependencies :transform (:id object) (deps.calculation/upstream-deps:transform object))
(when (not= (:dependency_analysis_version object) current-dependency-analysis-version)
(t2/update! :model/Transform (:id object) {:dependency_analysis_version current-dependency-analysis-version}))
(drop-outdated-target-dep! object))))
(derive ::transform-delete :metabase/event)
(derive :event/delete-transform ::transform-delete)
(methodical/defmethod events/publish-event! ::transform-delete
[_ {:keys [id]}]
(when (premium-features/has-feature? :dependencies)
;; TODO: (Braden 09/18/2025) Shouldn't we be deleting the downstream deps for dead edges as well as upstream?
(t2/delete! :model/Dependency :from_entity_type :transform :from_entity_id id)))
;; On *executing* a transform, its (freshly synced) output table is made to depend on the transform.
;; (And if the target has changed, the old table's dep on the transform is dropped.)
;; The upstream deps of the transform are not touched - those change only when the transform is edited.
(derive ::transform-run :metabase/event)
(derive :event/transform-run-complete ::transform-run)
(defn- transform-table-deps! [{:keys [db-id output-schema output-table transform-id] :as _details}]
(let [;; output-table is a keyword like :my_schema/my_table
table-name (name output-table)]
(when-let [table-id (t2/select-one-fn :id :model/Table :db_id db-id :schema output-schema :name table-name)]
(replace-dependencies :table table-id {:transform #{transform-id}}))))
(methodical/defmethod events/publish-event! ::transform-run
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(transform-table-deps! object)))

View File

@@ -0,0 +1,38 @@
(ns metabase-enterprise.dependencies.native-validation
(:require
[metabase.driver :as driver]
[metabase.lib.core :as lib]
[metabase.query-processor :as qp]))
(defn- compile-query [metadata-provider query]
(->> (lib/query metadata-provider query)
lib/add-parameters-for-template-tags
(qp/compile-query-with-metadata-provider metadata-provider)
:query))
(defn validate-native-query
"Compiles a (native) query and validates that the fields and tables it refers to really exist.
Returns either nil or a list of errors."
[driver metadata-provider query]
(->> (compile-query metadata-provider query)
(driver/validate-native-query-fields driver metadata-provider)))
(defn native-result-metadata
"Compiles a (native) query and calculates its result metadata"
[driver metadata-provider query]
(->> (compile-query metadata-provider query)
(driver/native-result-metadata driver metadata-provider)))
(defn native-query-deps
"Returns the upstream dependencies of a native query, as a set of `{:kind id}` pairs."
[driver metadata-provider query]
(let [compiled (compile-query metadata-provider query)]
(into (driver/native-query-deps driver compiled metadata-provider)
(keep #(case (:type %)
"snippet" {:snippet (:snippet-id %)}
:snippet {:snippet (:snippet-id %)}
"card" {:card (:card-id %)}
:card {:card (:card-id %)}
nil))
(-> query :native :template-tags vals))))

View File

@@ -0,0 +1,191 @@
(ns metabase-enterprise.dependencies.task.backfill
"Implements a task that brings all entities with dependencies tracked in the dependency table
(see [[metabase-enterprise.dependencies.models.dependency]]) up to date, that is, makes sure
the dependency table contains fresh entries.
This is done by finding entities whose dependency_analysis_version is less than
[[metabase-enterprise.dependencies.models.dependency/current-dependency-analysis-version]] and
updates this field. In most cases this triggers an event handler that calculates the dependencies
and populates the dependency table. For cards, the event is emitted by the job handler itself,
because the update of the record doesn't trigger the event handler. "
(:require
[clojurewerkz.quartzite.jobs :as jobs]
[clojurewerkz.quartzite.scheduler :as qs]
[clojurewerkz.quartzite.triggers :as triggers]
[environ.core :as env]
[java-time.api :as t]
[metabase-enterprise.dependencies.models.dependency :as models.dependency]
[metabase.config.core :as config]
[metabase.events.core :as events]
[metabase.premium-features.core :as premium-features]
[metabase.task.core :as task]
[metabase.util.log :as log]
[toucan2.core :as t2])
(:import
(java.util Map Set)
(java.util.concurrent ConcurrentHashMap)))
(set! *warn-on-reflection* true)
(defn- current-millis
"Returns the current epoch millis. Uses java-time so that clock settings can be used in tests."
[]
(t/to-millis-from-epoch (t/instant)))
(def ^:private entities [:model/Card :model/Transform :model/NativeQuerySnippet])
;; In-memory state for tracking failed entities
;; Stores {:model/Type {id {:fail-count N :next-retry-timestamp M}}}
(def ^:private retry-state
(zipmap entities (repeatedly #(ConcurrentHashMap.))))
;; Stores {:model/Type #{id1 id2 ...}} for entities that have exceeded MAX_RETRIES
(def ^:private terminally-broken
(zipmap entities (repeatedly #(ConcurrentHashMap/newKeySet))))
(def ^:private MAX_RETRIES 5)
(defn- long-env-value
[env-var-kw default]
(or (parse-long (env/env env-var-kw "")) default))
(defn- get-batch-size []
(long-env-value :mb-dependency-backfill-batch-size 20))
(defn- get-delay-minutes []
(long-env-value :mb-dependency-backfill-delay-minutes 60))
(defn- get-variance-minutes []
(long-env-value :mb-dependency-backfill-variance-minutes 10))
(defn- get-processable-ids [model-kw batch-size]
(let [target-version models.dependency/current-dependency-analysis-version
terminally-broken-ids ^Set (terminally-broken model-kw)
retry-state-map ^Map (retry-state model-kw)
current-time (current-millis)]
(into []
(comp
(map :id)
(filter (fn [id]
(and (not (.contains terminally-broken-ids id))
(let [entity-retry-info (.get retry-state-map id)]
(or (nil? entity-retry-info)
(>= current-time (:next-retry-timestamp entity-retry-info)))))))
(take batch-size))
(t2/reducible-select [model-kw :id] :dependency_analysis_version [:< target-version]))))
(defn- backfill-card!
[id target-version]
;; We don't want to change the card at all, we just want to update the dependency data and
;; mark the card as processed for this dependency analysis version.
(let [update-count (t2/update! :model/Card id :dependency_analysis_version [:< target-version]
{:dependency_analysis_version target-version})]
(when-let [card (and (pos? update-count)
(t2/select-one :model/Card id))]
(events/publish-event! :event/card-update {:object card :user-id config/internal-mb-user-id}))
update-count))
(defn- backfill-entity!
[model-kw id target-version]
(t2/with-transaction [_]
(case model-kw
:model/Card (backfill-card! id target-version)
(t2/update! model-kw id :dependency_analysis_version [:< target-version]
{:dependency_analysis_version target-version}))))
(defn- backfill-entity-batch!
[model-kw batch-size]
(let [model-name (name model-kw)
target-version models.dependency/current-dependency-analysis-version
retry-state-map ^Map (retry-state model-kw)
terminally-broken-set ^Set (terminally-broken model-kw)
ids (get-processable-ids model-kw batch-size)] ; Use the new get-processable-ids
(when (seq ids)
(log/infof "Processing a batch of %s %s(s)..." (count ids) model-name))
(reduce (fn [total id]
(+ total
(try
;; this should update the dependency table via a toucan2 update hook
(let [update-count (backfill-entity! model-kw id target-version)]
(.remove retry-state-map id)
update-count)
(catch Exception e
(let [current-time (current-millis)
entity-retry-info (.get retry-state-map id)
failure-count (inc (:fail-count entity-retry-info 0))
retry-minutes (* failure-count (get-delay-minutes))
new-next-retry-timestamp (+ current-time (* retry-minutes 60 1000))]
(if (> failure-count MAX_RETRIES)
(do (log/errorf e "Entity %s %s failed %d times, marking as terminally broken."
model-name id failure-count)
(.add terminally-broken-set id)
(.remove retry-state-map id)) ; Remove from retry map
(do (log/warnf e "Entity %s %s failed, failure count: %d, next retry no sooner than %d minutes."
model-name id failure-count retry-minutes)
(.put retry-state-map id {:fail-count failure-count
:next-retry-timestamp new-next-retry-timestamp}))))
0))))
0
ids)))
(defn- backfill-dependencies
"Job to backfill dependencies for all entities.
Returns true if a full batch has been selected, nil or false otherwise."
[]
(when (premium-features/has-feature? :dependencies)
(-> (reduce (fn [batch-size model-kw]
(if (< batch-size 1)
(reduced 0)
(let [processed (backfill-entity-batch! model-kw batch-size)]
(when (pos? processed)
(log/info "Updated" processed "entities."))
(- batch-size processed))))
(get-batch-size)
entities)
(< 1))))
(defn- has-pending-retries? []
(some (fn [^Map model-retry-state]
(not (.isEmpty model-retry-state)))
(vals retry-state)))
(declare schedule-next-run!)
(task/defjob
^{:doc "Backfill the dependency table."
org.quartz.DisallowConcurrentExecution true}
BackfillDependencies [ctx]
(log/info "Executing BackfillDependencies job...")
(let [full-batch-selected? (backfill-dependencies)
retries? (has-pending-retries?)]
(if (or full-batch-selected?
retries?)
(let [delay-seconds (* (get-delay-minutes) 60)
variance-seconds (* (get-variance-minutes) 60)
delay-in-seconds (max 0 (+ (- delay-seconds variance-seconds) (rand-int (* 2 variance-seconds))))]
(schedule-next-run! delay-in-seconds (.getScheduler ctx)))
(log/info "No more entities to backfill for, stopping."))))
(def ^:private job-key "metabase.task.dependency-backfill.job")
(def ^:private trigger-key "metabase.task.dependency-backfill.trigger")
(defn- schedule-next-run!
([delay-in-seconds] (schedule-next-run! delay-in-seconds nil))
([delay-in-seconds scheduler]
(let [start-at (java.util.Date. (long (+ (current-millis) (* delay-in-seconds 1000))))
trigger (triggers/build
(triggers/with-identity (triggers/key (str trigger-key \. (random-uuid))))
(triggers/for-job job-key)
(triggers/start-at start-at))]
(log/info "Scheduling next run at" start-at)
(if scheduler
;; re-scheduling from the job
(qs/add-trigger scheduler trigger)
;; first schedule
(let [job (jobs/build (jobs/of-type BackfillDependencies) (jobs/with-identity job-key))]
(task/schedule-task! job trigger))))))
(defmethod task/init! ::DependencyBackfill [_]
(if (pos? (get-batch-size))
(schedule-next-run! (rand-int (* (get-variance-minutes) 60)))
(log/info "Not starting dependency backfill job because the batch size is not positive")))

View File

@@ -1,6 +1,8 @@
(ns metabase-enterprise.metabot-v3.api
"`/api/ee/metabot-v3/` routes"
(:require
[clj-http.client :as http]
[clojure.string :as str]
[metabase-enterprise.metabot-v3.api.document]
[metabase-enterprise.metabot-v3.api.metabot]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
@@ -14,7 +16,11 @@
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.app-db.core :as app-db]
[metabase.premium-features.core :as premium-features]
[metabase.store-api.core :as store-api]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
@@ -81,6 +87,24 @@
(metabot-v3.context/log body :llm.log/fe->be)
(streaming-request body))
(api.macros/defendpoint :post "/feedback"
"Proxy Metabot feedback to Harbormaster, adding the premium embedding token."
[_route-params
_query-params
feedback :- :map]
(let [token (premium-features/premium-embedding-token)
base-url (store-api/store-api-url)]
(api/check-400 (not (or (str/blank? token) (str/blank? base-url)))
"Cannot build a request. The license token and/or Store api url are missing!")
(try
(http/post (str base-url "/api/v2/metabot/feedback/" token)
{:content-type :json
:body (json/encode feedback)})
api/generic-204-no-content
(catch Exception e
(log/error e "Failed to submit feedback to Harbormaster")
(throw e)))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/metabot-v3` routes."
(handlers/routes

View File

@@ -264,9 +264,10 @@
(let [options (cond-> arguments
(= (:with-field-values? arguments) false) (assoc :field-values-fn identity))
details (cond
(int? model-id) (u/prog1 (card-details model-id (assoc options :only-model true))
(api/check (= :model (:type <>)) 404
(format "ID %s is not a valid model id, it's a question" model-id)))
(int? model-id) (let [card (card-details model-id (assoc options :only-model true))]
(if (= :model (:type card))
card
(format "ID %s is not a valid model id, it's a question" model-id)))
(int? table-id) (table-details table-id options)
(string? table-id) (if-let [[_ card-id] (re-matches #"card__(\d+)" table-id)]
(card-details (parse-long card-id) options)

View File

@@ -107,6 +107,7 @@
use-verified-content? (if metabot-id
(:use_verified_content metabot)
false)
limit (or limit 50)
search-fn (fn [query]
(let [search-context (search/search-context
(merge
@@ -122,7 +123,7 @@
:current-user-perms @api/*current-user-permissions-set*
:context :metabot
:archived false
:limit (or limit 50)
:limit limit
:offset 0}
(when use-verified-content?
{:verified true})))
@@ -131,5 +132,5 @@
;; Create futures for parallel execution
futures (mapv #(future (search-fn %)) all-queries)
result-lists (mapv deref futures)
fused-results (reciprocal-rank-fusion result-lists)]
fused-results (take limit (reciprocal-rank-fusion result-lists))]
(map transform-search-result fused-results)))

View File

@@ -1,47 +0,0 @@
(ns metabase-enterprise.sandbox.query-processor.middleware.column-level-perms-check
(:require
[medley.core :as m]
[metabase.api.common :refer [*current-user-id*]]
;; legacy usage -- don't use Legacy MBQL utils in QP code going forward, prefer Lib. This will be updated to use
;; Lib only soon
^{:clj-kondo/ignore [:discouraged-namespace]}
[metabase.legacy-mbql.schema :as mbql.s]
[metabase.lib.core :as lib]
[metabase.lib.schema :as lib.schema]
[metabase.lib.util.match :as lib.util.match]
[metabase.premium-features.core :refer [defenterprise-schema]]
[metabase.query-processor.schema :as qp.schema]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(defn- field-ids [form]
(set (lib.util.match/match form
[:field (id :guard integer?) _]
id)))
(def ^:private ^:dynamic *gtap-perms*
"TODO: these used to be passed as a `:gtap-perms` key as part of QP context... not sure where this used to get passed
in."
nil)
(mu/defn- maybe-apply-column-level-perms-check*
[{{{source-query-fields :fields} :source-query} :query, :as query} :- ::mbql.s/Query]
(let [restricted-field-ids (and *gtap-perms*
(field-ids source-query-fields))]
(when (seq restricted-field-ids)
(let [fields-ids-in-query (field-ids (m/dissoc-in query [:query :source-query]))]
(when-not (every? restricted-field-ids fields-ids-in-query)
(log/warnf "User '%s' attempted to access an inaccessible field. Accessible fields %s, fields in query %s"
*current-user-id* (pr-str restricted-field-ids) (pr-str fields-ids-in-query))
(throw (ex-info (str (tru "User not able to query field")) {:status 403})))))))
(defenterprise-schema maybe-apply-column-level-perms-check :- ::qp.schema/qp
"Check column-level permissions if applicable."
:feature :sandboxes
[qp :- ::qp.schema/qp]
(mu/fn [query :- ::lib.schema/query
rff :- ::qp.schema/rff]
;; TODO (Cam 9/11/25) -- update this middleware to use Lib/MBQL 5
(maybe-apply-column-level-perms-check* (lib/->legacy-MBQL query))
(qp query rff)))

View File

@@ -10,6 +10,7 @@
[metabase.models.interface :as mi]
[metabase.permissions.core :as perms]
[metabase.users.models.user :as user]
[metabase.users.schema :as users.schema]
[metabase.util :as u]
[metabase.util.i18n :as i18n]
[metabase.util.malli :as mu]
@@ -68,7 +69,9 @@
[:Operations
[:sequential [:map
[:op ms/NonBlankString]
[:value [:or ms/NonBlankString ms/BooleanValue]]]]]])
[:value [:or [:map-of [:or :keyword :string]
[:or ms/NonBlankString ms/BooleanValue]]
ms/NonBlankString ms/BooleanValue]]]]]])
(def SCIMGroup
"Malli schema for a SCIM group."
@@ -169,7 +172,7 @@
:active (:is_active user)
:meta {:resourceType "User"}})
(mu/defn ^:private scim-user->mb :- user/NewUser
(mu/defn ^:private scim-user->mb :- users.schema/NewUser
"Given a SCIM user, returns a Metabase user."
[user]
(let [{email :userName name-obj :name locale :locale is-active? :active} user
@@ -280,6 +283,18 @@
:status 400
:status-code 400})))))))))
(defn- patch->user-updates
[acc path value]
(if (and (nil? path) (map? value))
(reduce-kv patch->user-updates acc value)
(let [path-str (some-> path name)]
(case path-str
"active" (assoc acc :is_active (Boolean/valueOf (u/lower-case-en value)))
"userName" (assoc acc :email value)
"name.givenName" (assoc acc :first_name value)
"name.familyName" (assoc acc :last_name value)
(throw-scim-error 400 (format "Unsupported path: %s" path-str))))))
(api.macros/defendpoint :patch ["/Users/:id" :id #"[^/]+"]
"Activate or deactivate a user. Supports specific replace operations, but not arbitrary patches."
[{:keys [id]} :- [:map
@@ -292,14 +307,8 @@
updates (reduce
(fn [acc operation]
(let [{:keys [op path value]} operation]
(if (= (u/lower-case-en op) "replace")
(case path
"active" (assoc acc :is_active (Boolean/valueOf (u/lower-case-en value)))
"userName" (assoc acc :email value)
"name.givenName" (assoc acc :first_name value)
"name.familyName" (assoc acc :last_name value)
(throw-scim-error 400 (format "Unsupported path: %s" path)))
acc)))
(cond-> acc
(= (u/lower-case-en op) "replace") (patch->user-updates path value))))
{}
(:Operations patch-ops))]
(t2/update! :model/User (u/the-id user) updates)

View File

@@ -87,8 +87,8 @@
(catch Exception e
(reset! err e)
(if full-stacktrace
(log/error e "Error during serialization")
(log/error (u/strip-error e "Error during serialization"))))))]
(log/error e "Error during serialization export")
(log/error (u/strip-error e "Error during serialization export"))))))]
{:archive (when (.exists dst)
dst)
:log-file (when (.exists log-file)
@@ -142,8 +142,8 @@
(catch Exception e
(reset! err e)
(if full-stacktrace
(log/error e "Error during serialization")
(log/error (u/strip-error e "Error during serialization"))))))]
(log/error e "Error during serialization import")
(log/error (u/strip-error e "Error during serialization import"))))))]
{:log-file log-file
:status (:status (ex-data @err))
:error-message (when @err

View File

@@ -270,8 +270,8 @@
(u/strip-error @err nil))})
(when @err
(if (:full-stacktrace opts)
(log/error @err "Error during serialization")
(log/error (u/strip-error @err "Error during deserialization")))
(log/error @err "Error during serialization export")
(log/error (u/strip-error @err "Error during serialization export")))
(throw (ex-info (ex-message @err) {:cmd/exit true})))
(log/info (format "Export to '%s' complete!" path) (u/emoji "🚛💨 📦"))
report))

View File

@@ -65,6 +65,7 @@
"DashboardBookmark"
"DataPermissions"
"DatabaseRouter"
"Dependency"
"DocumentBookmark"
"HTTPAction"
"ImplicitAction"

View File

@@ -16,9 +16,7 @@
[metabase.sso.core :as sso]
[metabase.util.i18n :refer [tru]]
[ring.util.response :as response]
[toucan2.core :as t2])
(:import
(java.net URLEncoder)))
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
@@ -99,7 +97,7 @@
(defn- session-data
[jwt {{redirect :return_to} :params, :as request}]
(let [redirect-url (or redirect (URLEncoder/encode "/"))]
(let [redirect-url (or redirect "/")]
(sso-utils/check-sso-redirect redirect-url)
(let [jwt-data (try
(jwt/unsign jwt (sso-settings/jwt-shared-secret)

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