Compare commits

..

79 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
310 changed files with 7244 additions and 3002 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

@@ -1361,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

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
@@ -135,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'
@@ -154,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 }}
@@ -183,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
@@ -202,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'
@@ -217,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
@@ -377,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'
@@ -398,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 }}
@@ -431,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]
@@ -491,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 }}
@@ -513,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
@@ -556,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 }}
@@ -575,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
@@ -770,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]
@@ -817,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 }}'
@@ -839,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
@@ -949,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:
@@ -970,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 }}
@@ -1011,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'
@@ -1030,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
@@ -1140,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 }}
@@ -1159,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]]

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!))

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

@@ -28,7 +28,7 @@ When embedding Metabot in your app, you can select a collection for Metabot:
1. Go to **Settings** > **Admin settings** > **AI**.
2. Click **Embedded Metabot**.
3. In the **Collection Embedded Metabot can use** section, click **Pick a collection**.
3. Select the collection that contains the models and metrics you want Metabot to use.
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. To give Metabot access to all collections, you can set the collection to the root collection, called "Our Analytics" (the default).
@@ -91,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

@@ -14971,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/",
@@ -15821,6 +15850,16 @@ redirect_from:
},
"value" : {
"anyOf" : [ {
"type" : "object",
"additionalProperties" : {
"anyOf" : [ {
"type" : "string",
"minLength" : 1
}, {
"type" : "boolean"
} ]
}
}, {
"type" : "string",
"minLength" : 1
}, {
@@ -16176,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",
@@ -16498,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"

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

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.

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).

View File

@@ -48,7 +48,6 @@ If you'd like to share your data with the good people of the internet, admins ca
| [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) | ✅ | ✅ | ✅ | ❌ | ❌ |
| [Actions on dashboards](../dashboards/actions.md) | ✅ | ✅ | ✅ | ❌ | ❌ |
| Embed individual Metabase components | ✅ | ✅ | ❌ | ❌ | ❌ |
| Manage access and interactivity per component | ✅ | ✅ | ❌ | ❌ | ❌ |
| Custom layouts | ✅ | ❌ | ❌ | ❌ | ❌ |
@@ -56,7 +55,7 @@ If you'd like to share your data with the good people of the internet, admins ca
\* Each embedding type allows data downloads by default, but only [Pro and Enterprise](https://www.metabase.com/pricing/) plans can disable data downloads.
\*\* Requires [Pro and Enterprise](https://www.metabase.com/pricing/) for any embedding type.
\*\* Requires a [Pro and Enterprise](https://www.metabase.com/pricing/) plan for any embedding type.
### Embedded analytics SDK vs JS

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

@@ -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:

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

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

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

@@ -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

@@ -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

@@ -134,7 +134,7 @@ describe("scenarios > embedding > sdk iframe embedding > authentication", () =>
(win as any).metabaseConfig = {
...(win as any).metabaseConfig,
fetchRequestToken: async () => {
const jwt = await getSignedJwtForUser(USERS.admin);
const jwt = await getSignedJwtForUser({ user: USERS.admin });
return { jwt };
},

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

@@ -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

@@ -94,8 +94,8 @@
feedback :- :map]
(let [token (premium-features/premium-embedding-token)
base-url (store-api/store-api-url)]
(when (or (str/blank? token) (str/blank? base-url))
(throw (ex-info "Cannot build a request. The license token and/or Store api url are missing!" {})))
(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

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]
@@ -171,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

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

@@ -5,7 +5,6 @@
[metabase-enterprise.sso.settings :as ee.sso.settings]
[metabase.premium-features.core :refer [defenterprise-schema]]
[metabase.sso.core :as sso]
[metabase.users.models.user :as user]
[metabase.util :as u]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2])
@@ -63,10 +62,10 @@
{:keys [sync-groups?], :as settings} :- sso/LDAPSettings]
(let [user (or (attribute-synced-user user-info)
(sso-utils/check-user-provisioning :ldap)
(-> (user/create-new-ldap-auth-user! {:first_name first-name
:last_name last-name
:email email
:login_attributes attributes})
(-> (sso/create-new-ldap-auth-user! {:first_name first-name
:last_name last-name
:email email
:login_attributes attributes})
(assoc :is_active true)))]
(u/prog1 user
(when sync-groups?

View File

@@ -15,6 +15,7 @@
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.driver.util :as driver.u]
[metabase.events.core :as events]
[metabase.request.core :as request]
[metabase.util :as u]
[metabase.util.i18n :refer [deferred-tru]]
@@ -119,15 +120,17 @@
(api/check (not (transforms.util/target-table-exists? body))
403
(deferred-tru "A table with that name already exists."))
(t2/with-transaction [_]
(let [tag-ids (:tag_ids body)
transform (t2/insert-returning-instance!
:model/Transform (select-keys body [:name :description :source :target :run_trigger]))]
;; Add tag associations if provided
(when (seq tag-ids)
(transform.model/update-transform-tags! (:id transform) tag-ids))
;; Return with hydrated tag_ids
(t2/hydrate transform :transform_tag_ids))))
(let [transform (t2/with-transaction [_]
(let [tag-ids (:tag_ids body)
transform (t2/insert-returning-instance!
:model/Transform (select-keys body [:name :description :source :target :run_trigger]))]
;; Add tag associations if provided
(when (seq tag-ids)
(transform.model/update-transform-tags! (:id transform) tag-ids))
;; Return with hydrated tag_ids
(t2/hydrate transform :transform_tag_ids)))]
(events/publish-event! :event/transform-create {:object transform :user-id api/*current-user-id*})
transform))
(api.macros/defendpoint :get "/:id"
"Get a specific transform."
@@ -188,28 +191,29 @@
[:tag_ids {:optional true} [:sequential ms/PositiveInt]]]]
(log/info "put transform" id)
(api/check-superuser)
(t2/with-transaction [_]
;; Cycle detection should occur within the transaction to avoid race
(let [old (t2/select-one :model/Transform id)
new (merge old body)
target-fields #(-> % :target (select-keys [:schema :name]))]
;; we must validate on a full transform object
(check-feature-enabled! new)
(check-database-feature new)
(when (transforms.util/query-transform? old)
(when-let [{:keys [cycle-str]} (transforms.ordering/get-transform-cycle new)]
(throw (ex-info (str "Cyclic transform definitions detected: " cycle-str)
{:status-code 400}))))
(api/check (not (and (not= (target-fields old) (target-fields new))
(transforms.util/target-table-exists? new)))
403
(deferred-tru "A table with that name already exists.")))
(t2/update! :model/Transform id (dissoc body :tag_ids))
;; Update tag associations if provided
(when (contains? body :tag_ids)
(transform.model/update-transform-tags! id (:tag_ids body)))
(t2/hydrate (t2/select-one :model/Transform id) :transform_tag_ids)))
(let [transform (t2/with-transaction [_]
;; Cycle detection should occur within the transaction to avoid race
(let [old (t2/select-one :model/Transform id)
new (merge old body)
target-fields #(-> % :target (select-keys [:schema :name]))]
;; we must validate on a full transform object
(check-feature-enabled! new)
(check-database-feature new)
(when (transforms.util/query-transform? old)
(when-let [{:keys [cycle-str]} (transforms.ordering/get-transform-cycle new)]
(throw (ex-info (str "Cyclic transform definitions detected: " cycle-str)
{:status-code 400}))))
(api/check (not (and (not= (target-fields old) (target-fields new))
(transforms.util/target-table-exists? new)))
403
(deferred-tru "A table with that name already exists.")))
(t2/update! :model/Transform id (dissoc body :tag_ids))
;; Update tag associations if provided
(when (contains? body :tag_ids)
(transform.model/update-transform-tags! id (:tag_ids body)))
(t2/hydrate (t2/select-one :model/Transform id) :transform_tag_ids))]
(events/publish-event! :event/transform-update {:object transform :user-id api/*current-user-id*})
transform))
(api.macros/defendpoint :delete "/:id"
"Delete a transform."

View File

@@ -1,9 +1,12 @@
(ns metabase-enterprise.transforms.core
"API namespace for the `metabase-enterprise.transform` module."
(:require
[metabase-enterprise.transforms.models.transform-run]
[metabase-enterprise.transforms.settings]
[potemkin :as p]))
(p/import-vars
[metabase-enterprise.transforms.settings
transform-timeout])
transform-timeout]
[metabase-enterprise.transforms.models.transform-run
timeout-run!])

View File

@@ -14,16 +14,12 @@
[run-id]
(t2/query-one
[(str "INSERT INTO transform_run_cancelation (run_id) "
"SELECT ? "
"WHERE "
" EXISTS (SELECT 1 "
" FROM transform_run "
" WHERE (transform_run.id = ?) "
" AND transform_run.is_active)"
" AND NOT EXISTS (SELECT 1 "
" FROM transform_run_cancelation "
" WHERE run_id = ?)")
run-id run-id run-id])
"SELECT transform_run.id "
"FROM transform_run "
"WHERE transform_run.id = ? "
"AND transform_run.is_active "
"AND NOT EXISTS (SELECT 1 FROM transform_run_cancelation WHERE run_id = ?)")
run-id run-id])
(t2/update! :model/TransformRun
:id run-id
{:status "canceling"})

View File

@@ -6,6 +6,7 @@
[metabase-enterprise.transforms-python.python-runner :as python-runner]
[metabase-enterprise.transforms-python.s3 :as s3]
[metabase-enterprise.transforms-python.settings :as transforms-python.settings]
[metabase-enterprise.transforms.core :as transforms]
[metabase-enterprise.transforms.instrumentation :as transforms.instrumentation]
[metabase-enterprise.transforms.util :as transforms.util]
[metabase.driver :as driver]
@@ -17,8 +18,10 @@
[metabase.util.log :as log]
[toucan2.core :as t2])
(:import
(java.io Closeable File)
(java.io Closeable)
(java.net SocketException)
(java.nio.file Files)
(java.nio.file.attribute FileAttribute)
(java.time Duration)))
(set! *warn-on-reflection* true)
@@ -240,13 +243,14 @@
;; TODO restructure things such that s3 can we swapped out for other transfer mechanisms
(with-open [log-future-ref (open-python-message-update-future! run-id message-log)
shared-storage-ref (s3/open-shared-storage! (:source-tables source))]
(let [driver (:engine db)
server-url (transforms-python.settings/python-runner-url)
_ (python-runner/copy-tables-to-s3! {:run-id run-id
:shared-storage @shared-storage-ref
:table-name->id (:source-tables source)
:cancel-chan cancel-chan})
_ (start-cancellation-process! server-url run-id cancel-chan) ; inherits lifetime of cancel-chan
(let [driver (:engine db)
server-url (transforms-python.settings/python-runner-url)
_ (python-runner/copy-tables-to-s3! {:run-id run-id
:shared-storage @shared-storage-ref
:table-name->id (:source-tables source)
:cancel-chan cancel-chan})
_ (start-cancellation-process! server-url run-id cancel-chan) ; inherits lifetime of cancel-chan
{:keys [status body] :as response}
(python-runner/execute-python-code-http-call!
{:server-url server-url
@@ -254,29 +258,33 @@
:run-id run-id
:table-name->id (:source-tables source)
:shared-storage @shared-storage-ref})
;; TODO temporary to keep more code stable while refactoring
;; no need to materialize these early (i.e output we can stream directly into a tmp file or db if small)
{:keys [output output-manifest events]} (python-runner/read-output-objects @shared-storage-ref)]
output-manifest (python-runner/read-output-manifest @shared-storage-ref)
events (python-runner/read-events @shared-storage-ref)]
(.close log-future-ref) ; early close to force any writes to flush
(when (seq events)
(replace-python-logs! message-log events))
(if (not= 200 status)
(throw (ex-info "Python runner call failed"
{:transform-message (i18n/tru "Python execution failure (exit code {0})" (:exit_code body "?"))
:status-code 400
:api-status-code status
:body body
:events events}))
(do
(when (:timeout body)
(transforms/timeout-run! run-id))
(throw (ex-info "Python runner call failed"
{:transform-message (i18n/tru "Python execution failure (exit code {0})" (:exit_code body "?"))
:status-code 400
:api-status-code status
:body body
:events events})))
(try
(let [temp-file (File/createTempFile "transform-output-" ".jsonl")]
(let [temp-path (Files/createTempFile "transform-output-" ".jsonl" (u/varargs FileAttribute))
temp-file (.toFile temp-path)]
(when-not (seq (:fields output-manifest))
(throw (ex-info "No fields in metadata"
{:metadata output-manifest
:raw-body body
:events events})))
(try
(with-open [writer (io/writer temp-file)]
(.write writer ^String output))
(with-open [in (python-runner/open-output @shared-storage-ref)]
(io/copy in temp-file))
(let [file-size (.length temp-file)]
(transforms.instrumentation/with-stage-timing [run-id :file-to-dwh]
(transfer-file-to-db driver db transform output-manifest temp-file))

View File

@@ -2,7 +2,6 @@
(:require
[clj-http.client :as http]
[clojure.java.io :as io]
[clojure.string :as str]
[medley.core :as m]
[metabase-enterprise.transforms-python.s3 :as s3]
[metabase-enterprise.transforms-python.settings :as transforms-python.settings]
@@ -20,7 +19,7 @@
[toucan2.core :as t2])
(:import
(clojure.lang PersistentQueue)
(java.io BufferedWriter File OutputStream OutputStreamWriter)
(java.io BufferedWriter File InputStream OutputStream OutputStreamWriter)
(java.nio.charset StandardCharsets)))
(set! *warn-on-reflection* true)
@@ -184,7 +183,7 @@
payload {:code code
:library (t2/select-fn->fn :path :source :model/PythonLibrary)
:timeout (* 30 60) ;; 30 mins harcoded for now
:timeout (transforms-python.settings/python-runner-timeout-seconds)
:request_id run-id
:output_url (:url output)
:output_manifest_url (:url output-manifest)
@@ -204,17 +203,24 @@
{:error string-if-error}))
string-if-error)))))
;; temporary, we should not need to realize data/events files into memory longer term
(defn read-output-objects
"Temporary function that strings/jsons stuff in S3 and returns it for compatibility."
(defn read-events
"Returns a vector of event contents (or nil if the event file does not exist)"
[{:keys [s3-client bucket-name objects]}]
(let [{:keys [output output-manifest events]} objects
output-content (s3/read-to-string s3-client bucket-name (:path output) nil)
output-manifest-content (s3/read-to-string s3-client bucket-name (:path output-manifest) "{}")
events-content (s3/read-to-string s3-client bucket-name (:path events))]
{:output output-content
:output-manifest (json/decode+kw output-manifest-content)
:events (mapv json/decode+kw (str/split-lines events-content))}))
;; note we expect :events to be a small file, limits ought to be enforced by the runner
(when-some [in (s3/open-object s3-client bucket-name (:path (:events objects)))]
(with-open [in in
rdr (io/reader in)]
(mapv json/decode+kw (line-seq rdr)))))
(defn read-output-manifest
"Return the output manifest map. Returns nil if it does not exist."
[{:keys [s3-client bucket-name objects]}]
(json/decode+kw (s3/read-to-string s3-client bucket-name (:path (:output-manifest objects)) "{}")))
(defn open-output
"Return an InputStream with the output jsonl contents. Close with .close. Returns nil if the object does not exist."
^InputStream [{:keys [s3-client bucket-name objects]}]
(s3/open-object s3-client bucket-name (:path (:output objects))))
(defn cancel-python-code-http-call!
"Calls the /cancel endpoint of the python runner. Returns immediately."

View File

@@ -4,7 +4,7 @@
[metabase.util.log :as log])
(:import
(clojure.lang IDeref)
(java.io Closeable File)
(java.io Closeable File InputStream)
(java.net URI)
(java.time Duration)
(software.amazon.awssdk.auth.credentials AwsBasicCredentials StaticCredentialsProvider)
@@ -183,19 +183,22 @@
(let [^PutObjectRequest request (put-object-request bucket-name key)]
(.putObject s3-client request (RequestBody/fromFile file))))
;; TODO optimize our ingestion to stream the data. reading a string will not work in any case for binary files.
(defn open-object
"Get back the contents of the given key as a InputStream."
^InputStream [^S3Client s3-client ^String bucket-name ^String key]
(try
(let [^GetObjectRequest request (get-object-request bucket-name key)]
(.getObject s3-client request))
(catch NoSuchKeyException _ nil)))
(defn read-to-string
"Get back the contents of the given key as a string."
([s3-client bucket-name key] (read-to-string s3-client bucket-name key ::throw))
([s3-client bucket-name key] (read-to-string s3-client bucket-name key nil))
([^S3Client s3-client ^String bucket-name ^String key not-found]
(try
(let [^GetObjectRequest request (get-object-request bucket-name key)
response (.getObject s3-client request)]
(slurp response))
(catch NoSuchKeyException e
(if (identical? ::throw not-found)
(throw e)
not-found)))))
(if-some [in (open-object s3-client bucket-name key)]
(with-open [ret in]
(slurp ret))
not-found)))
(defn delete
;; TODO better error handling

View File

@@ -113,3 +113,14 @@
:export? false
:encryption :no
:audit :getter)
(setting/defsetting python-runner-timeout-seconds
(deferred-tru "Timeout in seconds for Python script execution. Defaults to 30 minutes (1800 seconds).")
:type :integer
:visibility :admin
:default 1800 ;; 30 minutes
:feature :transforms-python
:doc false
:export? false
:encryption :no
:audit :getter)

View File

@@ -572,11 +572,11 @@
(mt/with-temp [:model/Card {card-1-id :id, :as card-1} {:collection_id (u/the-id collection)
:dataset_query card-1-query}]
(let [card-2-query (mt/native-query
{:query "SELECT * FROM {{card}}"
:template-tags {"card" {:name "card"
:display-name "card"
:type :card
:card-id card-1-id}}})]
{:query "SELECT * FROM {{card}}"
:template-tags {"card" {:name "card"
:display-name "card"
:type :card
:card-id card-1-id}}})]
(mt/with-temp [:model/Card card-2 {:collection_id (u/the-id collection)
:dataset_query card-2-query}]
(testing "should be able to read nested-nested Card if we have Collection permissions"
@@ -625,10 +625,10 @@
(mt/with-temp [:model/Collection collection]
(perms/grant-collection-read-permissions! (perms/all-users-group) collection)
(let [card-1-query (mt/native-query
{:query (str "SELECT id, name, category_id, latitude, longitude, price "
"FROM venues "
"ORDER BY id ASC "
"LIMIT 2")})]
{:query (str "SELECT id, name, category_id, latitude, longitude, price "
"FROM venues "
"ORDER BY id ASC "
"LIMIT 2")})]
(mt/with-temp [:model/Card {card-1-id :id, :as card-1} {:collection_id (u/the-id collection)
:dataset_query card-1-query}]
(let [card-2-query (mt/mbql-query nil

View File

@@ -6,8 +6,6 @@
[medley.core :as m]
[metabase-enterprise.metabot-v3.client-test :as client-test]
[metabase-enterprise.metabot-v3.util :as metabot.u]
[metabase.premium-features.token-check :as token-check]
[metabase.premium-features.token-check-test :as token-check-test]
[metabase.test :as mt]
[metabase.util.json :as json]
[toucan2.core :as t2]))
@@ -63,29 +61,23 @@
(deftest feedback-endpoint-test
(mt/with-premium-features #{:metabot-v3}
(with-redefs [token-check/fetch-token-status (fn [_x]
{:valid true
:status "fake"
:features ["test" "fixture"]
:trial false})]
(let [premium-token (token-check-test/random-token)
store-url "http://hm.example"]
(mt/with-random-premium-token! [premium-token]
(let [store-url "http://hm.example"]
(testing "Submits feedback to Harbormaster with token and base URL"
(mt/with-temporary-setting-values [premium-embedding-token premium-token
store-api-url store-url]
(let [captured (atom nil)
feedback {:metabot_id 1
:feedback {:positive true
:message_id "m-1"
:freeform_feedback "ok"}
:conversation_data {}
:version "v0.0.0"
:submission_time "2025-01-01T00:00:00Z"
:is_admin false}
(mt/with-temporary-setting-values [store-api-url store-url]
(let [captured (atom nil)
feedback {:metabot_id 1
:feedback {:positive true
:message_id "m-1"
:freeform_feedback "ok"}
:conversation_data {}
:version "v0.0.0"
:submission_time "2025-01-01T00:00:00Z"
:is_admin false}
expected-url (str store-url "/api/v2/metabot/feedback/" premium-token)]
(mt/with-dynamic-fn-redefs
[http/post (fn [url opts]
(reset! captured {:url url
(reset! captured {:url url
:body (json/decode+kw (:body opts))}))]
(let [_resp (mt/user-http-request :rasta :post 204 "ee/metabot-v3/feedback" feedback)]
(is (= {:url expected-url :body feedback}
@@ -95,11 +87,11 @@
(mt/with-temporary-setting-values [premium-embedding-token premium-token]
(mt/with-dynamic-fn-redefs
[http/post (fn [_url _opts]
(throw (ex-info "boom" {})))]
(throw (ex-info "boom" {:status 404})))]
(mt/user-http-request :rasta :post 500 "ee/metabot-v3/feedback" {:any "payload"}))))
;; We're not testing the branch where the store-api-url is missing because that defsetting
;; has the default value. It doesn't work well with `with-temporary-setting-values` helper.
(testing "Throws when premium token is missing"
(mt/with-temporary-setting-values [premium-embedding-token nil]
(mt/user-http-request :rasta :post 500 "ee/metabot-v3/feedback" {:foo "bar"})))))))
(mt/user-http-request :rasta :post 400 "ee/metabot-v3/feedback" {:foo "bar"})))))))

View File

@@ -32,11 +32,11 @@
["native"
{:gtaps {:venues {:query
(mt/native-query
{:query "SELECT id, name, category_id FROM venues WHERE category_id = {{cat}}"
:template-tags {"cat" {:id "__MY_CAT__"
:name "cat"
:display-name "Cat id"
:type :number}}})
{:query "SELECT id, name, category_id FROM venues WHERE category_id = {{cat}}"
:template-tags {"cat" {:id "__MY_CAT__"
:name "cat"
:display-name "Cat id"
:type :number}}})
:remappings {:cat [:variable [:template-tag "cat"]]}}}
:attributes {:cat 50}}]]]
(testing (format "GTAP rule is a %s query" query-type)

View File

@@ -88,17 +88,17 @@
(driver/with-driver (or driver/*driver* :h2)
(assert (driver.u/supports? driver/*driver* :native-parameters (mt/db)))
{:query (mt/native-query
{:query
(format-honeysql
{:select [:*]
:from [[(identifier :venues)]]
:where [:=
(identifier :venues :category_id)
[:raw "{{cat}}"]]
:order-by [[(identifier :venues :id) :asc]]})
{:query
(format-honeysql
{:select [:*]
:from [[(identifier :venues)]]
:where [:=
(identifier :venues :category_id)
[:raw "{{cat}}"]]
:order-by [[(identifier :venues :id) :asc]]})
:template_tags
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
:template_tags
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
:remappings {:cat ["variable" ["template-tag" "cat"]]}}))
(defn- parameterized-sql-with-join-sandbox-def
@@ -107,38 +107,38 @@
(driver/with-driver (or driver/*driver* :h2)
(assert (driver.u/supports? driver/*driver* :native-parameters (mt/db)))
{:query (mt/native-query
{:query
(format-honeysql
{:select [[(identifier :checkins :id)]
[(identifier :checkins :user_id)]
{:query
(format-honeysql
{:select [[(identifier :checkins :id)]
[(identifier :checkins :user_id)]
;; these columns are ILLEGAL, you're NOT allowed to add columns not in the original table.
;; We should complain loudly and then ignore them.
[(identifier :venues :name)]
[(identifier :venues :category_id)]]
:from [[(identifier :checkins)]]
:left-join [[(identifier :venues)]
[:= (identifier :checkins :venue_id) (identifier :venues :id)]]
:where [:=
(identifier :checkins :user_id)
[:raw "{{user}}"]]
:order-by [[(identifier :checkins :id) :asc]]})
[(identifier :venues :name)]
[(identifier :venues :category_id)]]
:from [[(identifier :checkins)]]
:left-join [[(identifier :venues)]
[:= (identifier :checkins :venue_id) (identifier :venues :id)]]
:where [:=
(identifier :checkins :user_id)
[:raw "{{user}}"]]
:order-by [[(identifier :checkins :id) :asc]]})
:template_tags
{"user" {:name "user"
:display-name "User ID"
:type :number
:required true}}})
:template_tags
{"user" {:name "user"
:display-name "User ID"
:type :number
:required true}}})
:remappings {:user ["variable" ["template-tag" "user"]]}}))
(defn- venue-names-native-sandbox-def []
(driver/with-driver (or driver/*driver* :h2)
{:query (mt/native-query
{:query
(format-honeysql
{:select [[(identifier :venues :id)]
[(identifier :venues :name)]]
:from [[(identifier :venues)]]
:order-by [[(identifier :venues :id) :asc]]})})}))
{:query
(format-honeysql
{:select [[(identifier :venues :id)]
[(identifier :venues :name)]]
:from [[(identifier :venues)]]
:order-by [[(identifier :venues :id) :asc]]})})}))
(defn- run-venues-count-query []
(mt/format-rows-by
@@ -599,13 +599,13 @@
(fn [{:keys [cols expected-cols]}]
(testing "A query with an equivalent native query sandbox should have the same metadata"
(met/with-gtaps! {:gtaps {:venues {:query (mt/native-query
{:query
(str "SELECT ID, NAME, CATEGORY_ID, LATITUDE, LONGITUDE, PRICE "
"FROM VENUES "
"WHERE CATEGORY_ID = {{cat}}")
{:query
(str "SELECT ID, NAME, CATEGORY_ID, LATITUDE, LONGITUDE, PRICE "
"FROM VENUES "
"WHERE CATEGORY_ID = {{cat}}")
:template_tags
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
:template_tags
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
:remappings {:cat ["variable" ["template-tag" "cat"]]}}}
:attributes {"cat" 50}}
(is (=? (expected-cols)
@@ -619,13 +619,13 @@
(testing (str "If columns are removed/reordered we should still merge in metadata for the columns we're "
"able to match from the original Table")
(met/with-gtaps! {:gtaps {:venues {:query (mt/native-query
{:query
(str "SELECT NAME, ID, LONGITUDE, PRICE "
"FROM VENUES "
"WHERE CATEGORY_ID = {{cat}}")
{:query
(str "SELECT NAME, ID, LONGITUDE, PRICE "
"FROM VENUES "
"WHERE CATEGORY_ID = {{cat}}")
:template_tags
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
:template_tags
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
:remappings {:cat ["variable" ["template-tag" "cat"]]}}}
:attributes {"cat" 50}}
(let [[id-col name-col _ _ longitude-col price-col] (expected-cols)]
@@ -636,15 +636,15 @@
(testing "Should be able to use a Saved Question with no source Metadata as a GTAP (EE #525)"
(met/with-gtaps! (mt/$ids
{:gtaps {:venues {:query (mt/native-query
{:query (str "SELECT DISTINCT VENUES.* "
"FROM VENUES "
"LEFT JOIN CHECKINS"
" ON CHECKINS.VENUE_ID = VENUES.ID "
"WHERE CHECKINS.USER_ID IN ({{sandbox}})")
:template-tags {"sandbox"
{:name "sandbox"
:display-name "Sandbox"
:type :text}}})
{:query (str "SELECT DISTINCT VENUES.* "
"FROM VENUES "
"LEFT JOIN CHECKINS"
" ON CHECKINS.VENUE_ID = VENUES.ID "
"WHERE CHECKINS.USER_ID IN ({{sandbox}})")
:template-tags {"sandbox"
{:name "sandbox"
:display-name "Sandbox"
:type :text}}})
:remappings {"user_id" [:variable [:template-tag "sandbox"]]}}
:checkins {:remappings {"user_id" [:dimension $checkins.user_id]}}}
:attributes {"user_id" 1}})
@@ -667,13 +667,13 @@
(testing "Should work with SQL queries that return less columns than there were in the original Table\n"
(met/with-gtaps! (mt/$ids
{:gtaps {:venues {:query (mt/native-query
{:query (str "SELECT * "
"FROM VENUES "
"WHERE VENUES.ID IN ({{sandbox}})")
:template-tags {"sandbox"
{:name "sandbox"
:display-name "Sandbox"
:type :text}}})
{:query (str "SELECT * "
"FROM VENUES "
"WHERE VENUES.ID IN ({{sandbox}})")
:template-tags {"sandbox"
{:name "sandbox"
:display-name "Sandbox"
:type :text}}})
:remappings {"venue_id" [:variable [:template-tag "sandbox"]]}}
:checkins {}}
:attributes {"venue_id" 1}})
@@ -709,11 +709,11 @@
(defn- do-with-sql-gtap! [sql f]
(met/with-gtaps! (mt/$ids
{:gtaps {:venues {:query (mt/native-query
{:query sql
:template-tags {"sandbox"
{:name "sandbox"
:display-name "Sandbox"
:type :text}}})
{:query sql
:template-tags {"sandbox"
{:name "sandbox"
:display-name "Sandbox"
:type :text}}})
:remappings {"venue_id" [:variable [:template-tag "sandbox"]]}}
:checkins {}}
:attributes {"venue_id" 1}})
@@ -1041,18 +1041,18 @@
(pr-str orders-gtap-card-has-metadata?)
(pr-str products-gtap-card-has-metadata?))
(met/with-gtaps! {:gtaps {:orders {:query (mt/native-query
{:query "SELECT * FROM ORDERS WHERE USER_ID={{uid}} AND TOTAL > 10"
:template-tags {"uid" {:display-name "User ID"
:id "1"
:name "uid"
:type :number}}})
{:query "SELECT * FROM ORDERS WHERE USER_ID={{uid}} AND TOTAL > 10"
:template-tags {"uid" {:display-name "User ID"
:id "1"
:name "uid"
:type :number}}})
:remappings {"user_id" [:variable [:template-tag "uid"]]}}
:products {:query (mt/native-query
{:query "SELECT * FROM PRODUCTS WHERE CATEGORY={{cat}} AND PRICE > 10"
:template-tags {"cat" {:display-name "Category"
:id "2"
:name "cat"
:type :text}}})
{:query "SELECT * FROM PRODUCTS WHERE CATEGORY={{cat}} AND PRICE > 10"
:template-tags {"cat" {:display-name "Category"
:id "2"
:name "cat"
:type :text}}})
:remappings {"user_cat" [:variable [:template-tag "cat"]]}}}
:attributes {"user_id" "1", "user_cat" "Widget"}}
(when orders-gtap-card-has-metadata?
@@ -1551,3 +1551,31 @@
[3]]
(mt/rows
(qp/process-query (query))))))))
(deftest datetime-extraction-to-int-test
(testing "Downloading CSV/XLSX for query with COUNT grouped by day of month should work for sandboxed users (#UXW-660)"
(met/with-gtaps! {:gtaps {:orders {:remappings {"user_id" ["variable" [:field (mt/id :orders :user_id) nil]]}}},
:attributes {"user_id" 1}}
(let [query (mt/mbql-query orders
{:aggregation [[:count]]
:breakout [[:field (mt/id :orders :created_at) {:effective-type :type/Integer
:base-type :type/DateTime
:temporal-unit :day-of-month}]]
:order-by [[:asc [:field (mt/id :orders :created_at) {:effective-type :type/Integer
:base-type :type/DateTime
:temporal-unit :day-of-month}]]]
:limit 5})]
(testing "Query should complete successfully with normal processing"
(let [result (qp/process-query query)]
(is (= :completed (:status result)))
(is (seq (mt/rows result)))))
(testing "Query should complete successfully when downloading as CSV"
(let [result (streaming.test-util/process-query-basic-streaming :csv query ["Created At: Day of month" "Count"])]
(is (seq result))
(testing "Results should include integer day-of-month values and counts"
(is (every? (fn [[day count]]
(and (string? day)
(string? count)
(let [day-int (parse-long day)]
(<= 1 day-int 31))))
(next result))))))))))

View File

@@ -702,14 +702,14 @@
:collection_id coll2-id
:dataset_query
(mt/native-query
{:query "select {{snippet}}"
:template-tags {"snippet"
{:display-name "Snippet 1",
:id s1-eid,
:name "snippet 1",
:snippet-id s1-id,
:snippet-name "snip",
:type "snippet"}}})}]
{:query "select {{snippet}}"
:template-tags {"snippet"
{:display-name "Snippet 1",
:id s1-eid,
:name "snippet 1",
:snippet-id s1-id,
:snippet-name "snip",
:type "snippet"}}})}]
(testing "native query snippets"
(testing "can belong to :snippets collections"
(let [ser (serdes/extract-one "NativeQuerySnippet" {} (t2/select-one :model/NativeQuerySnippet :id s1-id))]

View File

@@ -783,3 +783,39 @@
(is (= "Transforms are not supported on databases with DB routing enabled."
(mt/user-http-request :crowberto :put 400 (format "ee/transform/%s" (:id transform))
(assoc transform :name "Gadget Products 2")))))))))))
(deftest transform-revisions-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(mt/dataset transforms-dataset/transforms-test
(with-transform-cleanup! [table-name "transform_revisions_test"]
(let [test-transform-revisions (fn [action url req exp-revisions]
(let [transform (mt/user-http-request :crowberto action 200 url req)
transform-id (:id transform)]
(is (= exp-revisions (t2/count :model/Revision :model "Transform"
:model_id transform-id)))
(let [revision (t2/select-one :model/Revision :model "Transform"
:model_id transform-id :most_recent true)
rev-transform (:object revision)
removed #{:id :entity_id :created_at :updated_at}]
(is (every? #(not (contains? rev-transform %)) removed))
;; CI tries comparing "=" with := which fails
(is (=? (dissoc rev-transform :source) transform)))
transform-id))
gadget-req {:name "Gadget Products"
:description "The gadget products"
:source {:type "query"
:query (make-query "Gadget")}
:target {:type "table"
:schema (get-test-schema)
:name table-name}}
transform-id (test-transform-revisions :post "ee/transform" gadget-req 1)
widget-req {:name "Widget Products"
:description "The widget products"
:source {:type "query"
:query (make-query "Widget")}
:tag_ids [4]
:target {:type "table"
:schema (get-test-schema)
:name table-name}}]
(test-transform-revisions :put (str "ee/transform/" transform-id) widget-req 2)))))))

View File

@@ -0,0 +1,24 @@
(ns metabase-enterprise.transforms.revision-test
(:require
[clojure.test :refer :all]
[metabase.revisions.models.revision :as revision]))
(deftest transform-serialize-instance-test
(testing "serialize-instance for :model/Transform excludes the correct columns"
(let [transform {:id 1
:entity_id "wyQv6yHnXS-IqPrYm1osQ"
:created_at "2025-09-30T00:00:00Z"
:updated_at "2025-09-30T00:00:00Z"
:name "Test Transform"
:description "A test transform"
:source {:type :query
:query {:database 1
:type :query
:query {:source-table 2}}}
:target {:type :table
:name "transformed_table"
:schema "public"
:database 1}}
exp-serialized (dissoc transform :id :entity_id :created_at :updated_at)]
(is (= (revision/serialize-instance :model/Transform nil transform)
exp-serialized)))))

View File

@@ -29,21 +29,22 @@
"Execute a Python transform with the given code and tables"
[{:keys [code tables]}]
(with-open [shared-storage-ref (s3/open-shared-storage! (or tables {}))]
(let [server-url (transforms-python.settings/python-runner-url)
cancel-chan (a/promise-chan)
table-name->id (or tables {})
_ (python-runner/copy-tables-to-s3! {:run-id test-id
:shared-storage @shared-storage-ref
:table-name->id table-name->id
:cancel-chan cancel-chan})
response (python-runner/execute-python-code-http-call! {:server-url server-url
:code code
:run-id test-id
:table-name->id table-name->id
:shared-storage @shared-storage-ref})
{:keys [output output-manifest events]} (python-runner/read-output-objects @shared-storage-ref)]
(let [server-url (transforms-python.settings/python-runner-url)
cancel-chan (a/promise-chan)
table-name->id (or tables {})
_ (python-runner/copy-tables-to-s3! {:run-id test-id
:shared-storage @shared-storage-ref
:table-name->id table-name->id
:cancel-chan cancel-chan})
response (python-runner/execute-python-code-http-call! {:server-url server-url
:code code
:run-id test-id
:table-name->id table-name->id
:shared-storage @shared-storage-ref})
events (python-runner/read-events @shared-storage-ref)
output-manifest (python-runner/read-output-manifest @shared-storage-ref)]
(merge (:body response)
{:output output
{:output (when-some [in (python-runner/open-output @shared-storage-ref)] (with-open [in in] (slurp in)))
:output-manifest output-manifest
:stdout (->> events (filter #(= "stdout" (:stream %))) (map :message) (str/join "\n"))
:stderr (->> events (filter #(= "stderr" (:stream %))) (map :message) (str/join "\n"))}))))

View File

@@ -6,6 +6,7 @@
[metabase-enterprise.transforms.test-util :as transforms.tu :refer [with-transform-cleanup!]]
[metabase-enterprise.transforms.util :as transforms.util]
[metabase.test :as mt]
[metabase.test.util :as test.util]
[toucan2.core :as t2])
(:import
(java.util.concurrent CountDownLatch)))
@@ -92,3 +93,37 @@
(is (= [[1 "a"] [2 "b"] [3 "c"]] (transforms.tu/table-rows table-name))
"Table should contain the expected data after swap")))))))))))
(deftest python-transform-timeout-status-test
(testing "Python transform execution sets correct timeout status when script times out"
(mt/test-drivers #{:postgres}
(mt/with-premium-features #{:transforms-python}
(mt/dataset transforms-dataset/transforms-test
(let [schema (t2/select-one-fn :schema :model/Table (mt/id :transforms_products))]
(with-transform-cleanup! [target {:type "table"
:schema schema
:name "timeout_test"}]
(test.util/with-temporary-setting-values [python-runner-timeout-seconds 5]
(let [long-running-code (str "import time\n"
"import pandas as pd\n"
"\n"
"def transform():\n"
" time.sleep(10) # Sleep longer than timeout\n"
" return pd.DataFrame({'result': ['should_not_reach_here']})")
transform-def {:name "Python Transform Timeout Test"
:source {:type "python"
:source-tables {}
:body long-running-code}
:target (assoc target :database (mt/id))}]
(mt/with-temp [:model/Transform transform transform-def]
(let [{:keys [run_id]} (try
(transforms-python.execute/execute-python-transform! transform {:run-method :manual})
(catch Exception _
;; We expect this to fail due to timeout
{:run_id (t2/select-one-fn :id :model/TransformRun
:transform_id (:id transform)
{:order-by [[:start_time :desc]]})}))
run-status (t2/select-one-fn :status :model/TransformRun :id run_id)]
(testing "Transform run should have timeout status"
(is (= :timeout run-status)
"Transform run status should be :timeout when Python script times out")))))))))))))

View File

@@ -15,6 +15,7 @@
[metabase.test :as mt]
[metabase.test.data.interface :as tx]
[metabase.test.data.sql :as sql.tx]
[metabase.test.util :as tu]
[metabase.util :as u]
[metabase.util.json :as json]
[toucan2.core :as t2]))
@@ -69,10 +70,11 @@
:run-id test-id
:table-name->id table-name->id
:shared-storage @shared-storage-ref})
{:keys [output output-manifest events]} (python-runner/read-output-objects @shared-storage-ref)]
events (python-runner/read-events @shared-storage-ref)
output-manifest (python-runner/read-output-manifest @shared-storage-ref)]
;; not sure about munging this all together but its what tests expect for now
(merge (:body response)
{:output output
{:output (when-some [in (python-runner/open-output @shared-storage-ref)] (with-open [in in] (slurp in)))
:output-manifest output-manifest
:stdout (->> events (filter #(= "stdout" (:stream %))) (map :message) (str/join "\n"))
:stderr (->> events (filter #(= "stderr" (:stream %))) (map :message) (str/join "\n"))}))))
@@ -493,3 +495,18 @@
;; cleanup
(driver/drop-table! driver db-id qualified-table-name)))))
(deftest python-runner-timeout-test
(testing "Python script execution respects timeout setting"
(mt/with-premium-features #{:transforms-python}
(tu/with-temporary-setting-values [python-runner-timeout-seconds 5]
(let [long-running-code (str "import time\n"
"import pandas as pd\n"
"\n"
"def transform():\n"
" time.sleep(10) # Sleep longer than timeout\n"
" return pd.DataFrame({'result': ['should_not_reach_here']})")
result (execute! {:code long-running-code})]
(testing "Script should timeout after 5 seconds"
(is (contains? result :error))
(is (str/includes? (:error result) "timeout"))))))))

View File

@@ -15,8 +15,7 @@
(java.net ConnectException)
(java.nio.file Files)
(java.nio.file.attribute FileAttribute)
(software.amazon.awssdk.services.s3 S3Client)
(software.amazon.awssdk.services.s3.model NoSuchKeyException)))
(software.amazon.awssdk.services.s3 S3Client)))
(set! *warn-on-reflection* true)
@@ -43,7 +42,7 @@
key (str "test-object-" (random-uuid) ".txt")
body (str "Hello, S3! My secret is:" (random-uuid))]
(is (thrown? NoSuchKeyException (s3/read-to-string s3-client bucket key)))
(is (nil? (s3/read-to-string s3-client bucket key)))
(is (= :default (s3/read-to-string s3-client bucket key :default)))
(let [tmp-file (Files/createTempFile "s3-test" ".txt" (into-array FileAttribute []))]
@@ -57,7 +56,7 @@
(s3/delete s3-client bucket key)
(is (thrown? NoSuchKeyException (s3/read-to-string s3-client bucket key)))
(is (nil? (s3/read-to-string s3-client bucket key)))
(is (= :default (s3/read-to-string s3-client bucket key :default)))
(finally

View File

@@ -0,0 +1,4 @@
.SqlParametersList {
/* Reset ResponsiveList margins */
margin: 0;
}

View File

@@ -0,0 +1,77 @@
import { useMemo } from "react";
import { useSdkQuestionContext } from "embedding-sdk-bundle/components/private/SdkQuestion/context";
import { ResponsiveParametersList } from "metabase/query_builder/components/ResponsiveParametersList";
import * as Lib from "metabase-lib";
import type { ParameterId } from "metabase-types/api";
import SqlParametersListS from "./SqlParametersList.module.css";
/**
* Parameters list for SQL questions
*
* @function
* @category InteractiveQuestion
*/
export const SqlParametersList = () => {
const {
question,
originalQuestion,
parameterValues,
updateParameterValues,
hiddenParameters,
} = useSdkQuestionContext();
const isNativeQuestion = useMemo(() => {
if (!question) {
return false;
}
const { isNative } = Lib.queryDisplayInfo(question.query());
return isNative;
}, [question]);
const parameters = useMemo(() => {
if (!question || !originalQuestion) {
return [];
}
const originalParameters = originalQuestion.card().parameters ?? [];
return question
.parameters()
.filter(
({ id, slug }) =>
originalParameters.find(
(originalParameter) => originalParameter.id === id,
) && !hiddenParameters?.includes(slug),
);
}, [question, originalQuestion, hiddenParameters]);
if (!question || !isNativeQuestion) {
return null;
}
const setParameterValue = (parameterId: ParameterId, value: string) => {
const nextParameterValues = {
...parameterValues,
[parameterId]: value,
};
updateParameterValues(nextParameterValues);
};
return (
<ResponsiveParametersList
className={SqlParametersListS.SqlParametersList}
cardId={question.id()}
dashboardId={question.dashboardId() ?? undefined}
parameters={parameters}
setParameterValue={setParameterValue}
enableParameterRequiredBehavior
commitImmediately={false}
isSortable={false}
/>
);
};

View File

@@ -0,0 +1 @@
export * from "./SqlParametersList";

View File

@@ -14,3 +14,4 @@ export * from "./Title";
export * from "./Visualization";
export * from "./VisualizationButton";
export * from "./QuestionSettings";
export * from "./SqlParametersList";

View File

@@ -37,6 +37,7 @@ const _BadgeListItem = (
onRemoveItem?.();
}}
className={S.BadgeListRemoveButton}
data-testid="badge-remove-button"
>
<Icon name="close" c="var(--mb-color-text-brand)" size={10} />
</ActionIcon>

View File

@@ -45,6 +45,7 @@ export const SdkQuestionProvider = ({
entityTypes,
targetCollection,
initialSqlParameters,
hiddenParameters,
withDownloads,
targetDashboardId,
backToDashboard,
@@ -94,6 +95,7 @@ export const SdkQuestionProvider = ({
const {
question,
originalQuestion,
parameterValues,
queryResults,
@@ -104,6 +106,7 @@ export const SdkQuestionProvider = ({
replaceQuestion,
loadAndQueryQuestion,
updateQuestion,
updateParameterValues,
navigateToNewCard,
} = useLoadQuestion({
questionId,
@@ -144,6 +147,7 @@ export const SdkQuestionProvider = ({
queryQuestion,
replaceQuestion,
updateQuestion,
updateParameterValues,
navigateToNewCard:
userNavigateToNewCard !== undefined
? userNavigateToNewCard
@@ -151,6 +155,7 @@ export const SdkQuestionProvider = ({
plugins,
question,
originalQuestion,
parameterValues,
queryResults,
mode,
onSave: handleSave,
@@ -160,6 +165,7 @@ export const SdkQuestionProvider = ({
withDownloads,
onRun,
backToDashboard,
hiddenParameters,
onVisualizationChange,
};

View File

@@ -34,6 +34,11 @@ type SdkQuestionConfig = {
**/
initialSqlParameters?: SqlParameterValues;
/**
* A list of parameters to hide.
**/
hiddenParameters?: string[];
/**
* Enables the ability to download results in the interactive question.
*/
@@ -128,6 +133,7 @@ export type SdkQuestionContextType = Omit<
| "targetCollection"
| "withDownloads"
| "backToDashboard"
| "hiddenParameters"
| "onVisualizationChange"
> & {
plugins: SdkQuestionConfig["componentPlugins"] | null;

View File

@@ -246,7 +246,12 @@ export const SdkQuestionDefaultView = ({
)}
</Stack>
<Box className={InteractiveQuestionS.Main} p="sm" w="100%" h="100%">
<Box
className={cx(InteractiveQuestionS.Main, "sdk-question-main")}
p="sm"
w="100%"
h="100%"
>
<Box className={InteractiveQuestionS.Content}>
{isEditorOpen ? (
<Editor onApply={closeEditor} />

View File

@@ -109,7 +109,9 @@ export const SdkUsageProblemBanner = ({
<ExternalLink role="link" href={problem.documentationUrl}>
<Button
fz="sm"
rightSection={<Icon name="external" size="1rem" />}
rightSection={
<Icon aria-hidden name="external" size="1rem" />
}
ff={fontFamily}
className={S.DocsButton}
size="md"

View File

@@ -8,7 +8,10 @@ import { mockSettings } from "__support__/settings";
import { screen, within } from "__support__/ui";
import { renderWithSDKProviders } from "embedding-sdk-bundle/test/__support__/ui";
import { createMockSdkConfig } from "embedding-sdk-bundle/test/mocks/config";
import { createMockSdkState } from "embedding-sdk-bundle/test/mocks/state";
import {
createMockSdkState,
createMockTokenState,
} from "embedding-sdk-bundle/test/mocks/state";
import {
createMockSettings,
createMockTokenFeatures,
@@ -55,10 +58,18 @@ const setup = (options: Options) => {
"enable-embedding-simple": options.isSimpleEmbeddingEnabled ?? true,
});
const MINUTE = 60;
const state = createMockState({
settings: mockSettings(settingValues),
currentUser: TEST_USER,
sdk: createMockSdkState(),
sdk: createMockSdkState({
token: createMockTokenState({
token: {
id: "123",
exp: Math.round(Date.now() / 1000) + 10 * MINUTE,
},
}),
}),
});
setupSettingsEndpoints([]);

View File

@@ -13,7 +13,10 @@ import {
createMockApiKeyConfig,
createMockSdkConfig,
} from "embedding-sdk-bundle/test/mocks/config";
import { createMockSdkState } from "embedding-sdk-bundle/test/mocks/state";
import {
createMockSdkState,
createMockTokenState,
} from "embedding-sdk-bundle/test/mocks/state";
import type { MetabaseAuthConfig } from "embedding-sdk-bundle/types";
import {
createMockSettings,
@@ -37,9 +40,10 @@ interface Options {
hasEmbeddingFeature?: boolean;
isEmbeddingSdkEnabled?: boolean;
isDevelopmentMode?: boolean;
hasExpirationClaim?: boolean;
}
const setup = (options: Options) => {
const setup = ({ hasExpirationClaim = true, ...options }: Options) => {
const tokenFeatures = createMockTokenFeatures({
embedding_sdk: options.hasEmbeddingFeature ?? true,
development_mode: options.isDevelopmentMode ?? false,
@@ -50,10 +54,20 @@ const setup = (options: Options) => {
"enable-embedding-sdk": options.isEmbeddingSdkEnabled ?? true,
});
const MINUTE = 60;
const state = createMockState({
settings: mockSettings(settingValues),
currentUser: TEST_USER,
sdk: createMockSdkState(),
sdk: createMockSdkState({
token: createMockTokenState({
token: {
id: "123",
exp: hasExpirationClaim
? Math.round(Date.now() / 1000) + 10 * MINUTE
: null,
},
}),
}),
});
setupSettingsEndpoints([]);
@@ -98,7 +112,7 @@ describe("SdkUsageProblemDisplay", () => {
).toBeInTheDocument();
const docsLink = within(card).getByRole("link", {
name: /Documentation/,
name: "Documentation",
});
expect(docsLink).toHaveAttribute(
@@ -127,12 +141,12 @@ describe("SdkUsageProblemDisplay", () => {
).toBeInTheDocument();
const docsLink = within(card).getByRole("link", {
name: /Documentation/,
name: "Documentation",
});
expect(docsLink).toHaveAttribute(
"href",
"https://www.metabase.com/docs/latest/embedding/sdk/authentication#authenticating-people-from-your-server",
"https://www.metabase.com/docs/latest/embedding/sdk/authentication#2-add-a-new-endpoint-to-your-backend-to-handle-authentication",
);
});
@@ -180,7 +194,7 @@ describe("SdkUsageProblemDisplay", () => {
).toBeInTheDocument();
const docsLink = within(card).getByRole("link", {
name: /Documentation/,
name: "Documentation",
});
expect(docsLink).toHaveAttribute(
@@ -211,7 +225,7 @@ describe("SdkUsageProblemDisplay", () => {
).toBeInTheDocument();
const docsLink = within(card).getByRole("link", {
name: /Documentation/,
name: "Documentation",
});
expect(docsLink).toHaveAttribute(
@@ -220,6 +234,38 @@ describe("SdkUsageProblemDisplay", () => {
);
});
it('should show a warning when JWT token does not contain the "exp" claim', async () => {
setup({
authConfig: createMockSdkConfig(),
isEmbeddingSdkEnabled: true,
isDevelopmentMode: false,
hasExpirationClaim: false,
});
await userEvent.click(screen.getByTestId(PROBLEM_INDICATOR_TEST_ID));
const card = screen.getByTestId(PROBLEM_CARD_TEST_ID);
expect(
within(card).getByText("This embed is powered by the Metabase SDK."),
).toBeInTheDocument();
expect(
within(card).getByText(
`The JWT token is missing the "exp" (expiration) claim. We will disallow tokens without "exp" in a future release. Please add "exp" to the token payload.`,
),
).toBeInTheDocument();
const docsLink = within(card).getByRole("link", {
name: "Documentation",
});
expect(docsLink).toHaveAttribute(
"href",
"https://www.metabase.com/docs/latest/embedding/sdk/authentication#2-add-a-new-endpoint-to-your-backend-to-handle-authentication",
);
});
it("hides the problem when 'hide' is clicked", async () => {
setup({
authConfig: createMockSdkConfig(),

View File

@@ -1,4 +1,6 @@
import { useSdkUsageProblem } from "embedding-sdk-bundle/hooks/private/use-sdk-usage-problem";
import { getSessionTokenState } from "embedding-sdk-bundle/store/selectors";
import { useSdkSelector } from "embedding-sdk-bundle/store/use-sdk-selector";
import type { MetabaseAuthConfig } from "embedding-sdk-bundle/types";
import { EMBEDDING_SDK_PORTAL_ROOT_ELEMENT_ID } from "metabase/embedding-sdk/config";
import { Box, Portal } from "metabase/ui";
@@ -15,7 +17,13 @@ export const SdkUsageProblemDisplay = ({
authConfig,
allowConsoleLog,
}: Props) => {
const usageProblem = useSdkUsageProblem({ authConfig, allowConsoleLog });
const session = useSdkSelector((state) => getSessionTokenState(state).token);
const usageProblem = useSdkUsageProblem({
authConfig,
allowConsoleLog,
session,
});
if (!usageProblem) {
return null;

View File

@@ -17,6 +17,7 @@ const propsSchema: Yup.SchemaOf<InteractiveQuestionProps> = Yup.object({
entityTypes: Yup.mixed().optional(),
height: Yup.mixed().optional(),
initialSqlParameters: Yup.mixed().optional(),
hiddenParameters: Yup.mixed().optional(),
isSaveEnabled: Yup.mixed().optional(),
onBeforeSave: Yup.mixed().optional(),
onNavigateBack: Yup.mixed().optional(),

View File

@@ -16,6 +16,7 @@ import {
QuestionVisualization,
SaveButton,
SdkSaveQuestionForm,
SqlParametersList,
Summarize,
SummarizeDropdown,
Title,
@@ -67,6 +68,7 @@ export type InteractiveQuestionComponents = {
BreakoutDropdown: typeof BreakoutDropdown;
DownloadWidget: typeof DownloadWidget;
DownloadWidgetDropdown: typeof DownloadWidgetDropdown;
SqlParametersList: typeof SqlParametersList;
};
export const _InteractiveQuestion = (props: InteractiveQuestionProps) => (
@@ -97,6 +99,7 @@ const subComponents: InteractiveQuestionComponents = {
DownloadWidget: DownloadWidget,
DownloadWidgetDropdown: DownloadWidgetDropdown,
VisualizationButton: VisualizationButton,
SqlParametersList: SqlParametersList,
};
export const InteractiveQuestion = Object.assign(

View File

@@ -1,146 +0,0 @@
/* TODO: rtl support */
@keyframes chat-bar-slide-in {
0% {
opacity: 0;
transform: translate(-50%, 2rem);
transform-origin: top;
}
100% {
opacity: 1;
transform: translate(-50%, 0);
transform-origin: top;
}
}
.container {
position: relative;
max-width: 41.5rem;
width: 100%;
border: 1px solid var(--mb-color-brand);
border-radius: 22px;
}
.innerContainer {
padding: 0 1rem;
border-radius: 22px;
align-items: center;
box-sizing: border-box;
border: solid 1.5px transparent;
background: var(--mb-color-bg-white);
transition: background 150ms ease;
}
.innerContainer::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to right, #2e96f1, #d932f4);
z-index: -1;
margin: -1.5px;
border-radius: 24px;
}
.innerContainerLoading {
background: var(--mb-color-bg-light);
}
.innerContainerExpanded {
padding: 1rem;
align-items: flex-start;
height: 8rem;
}
.input {
caret-color: var(--mb-color-brand);
}
.textarea {
textarea {
box-sizing: border-box;
border: none;
border-radius: 0;
background: transparent;
outline: none;
width: 100%;
caret-color: var(--mb-color-brand);
padding: 0.5rem 0;
line-height: 1.7;
color: var(--mb-color-text-dark);
}
}
.textareaExpanded {
textarea {
padding: 0;
}
}
.textareaLoading {
textarea {
font-weight: bold;
&:disabled {
opacity: 1;
background: transparent;
}
}
}
/* nesting the style above keeps it from getting applied */
.textareaLoading textarea::placeholder,
.textareaLoading textarea:disabled::placeholder {
color: var(--mb-color-brand);
}
@keyframes responses-slide-in {
0% {
opacity: 0;
transform: translate(0, -0.5rem);
transform-origin: top;
}
100% {
opacity: 1;
transform: translate(0, 0);
transform-origin: top;
}
}
.responses {
position: absolute;
left: 0;
top: 100%;
margin-top: 0.25rem;
pointer-events: none; /* allow user to click through transparent area behind repsonses */
animation: responses-slide-in 150ms ease forwards;
transition-property: transform, opacity;
}
.response {
padding: 0.5rem 1rem;
display: flex;
background: var(--mb-color-brand);
font-size: 0.875rem;
font-weight: 700;
border-radius: 1rem;
color: white;
pointer-events: auto;
margin-top: 0.25rem;
position: relative;
}
.responseMessage {
flex-grow: 1;
}
button.responseDismissBtn {
color: #cbe2f7;
height: 1rem;
cursor: pointer;
margin-left: 0.5rem;
}

View File

@@ -1,181 +0,0 @@
import cx from "classnames";
import { useEffect, useState } from "react";
import { t } from "ttag";
import { useSdkDispatch } from "embedding-sdk-bundle/store";
import {
Box,
Flex,
Icon,
Loader,
Textarea,
Tooltip,
UnstyledButton,
} from "metabase/ui";
import {
useMetabotAgent,
useMetabotChatHandlers,
} from "metabase-enterprise/metabot/hooks";
import {
cancelInflightAgentRequests,
resetConversationId,
} from "metabase-enterprise/metabot/state";
import Styles from "./MetabotChatEmbedding.module.css";
const MIN_INPUT_HEIGHT = 42;
export const MetabotChatEmbedding = () => {
const metabot = useMetabotAgent();
const { handleSubmitInput, handleResetInput } = useMetabotChatHandlers();
const resetInput = () => {
handleResetInput();
setInputExpanded(false);
};
const startNewConversation = () => {
resetInput();
metabot.resetConversation();
};
const [inputExpanded, setInputExpanded] = useState(false);
const handleMaybeExpandInput = () => {
const textarea = metabot.promptInputRef?.current;
if (!textarea) {
return;
}
const isMultiRow = textarea.scrollHeight > MIN_INPUT_HEIGHT;
if (inputExpanded !== isMultiRow) {
setInputExpanded(isMultiRow);
}
// keep scrolled to bottom
textarea.scrollTop = Math.max(MIN_INPUT_HEIGHT, textarea.scrollHeight);
};
const inputPlaceholder = t`Tell me to do something, or ask a question`;
const placeholder = metabot.isDoingScience
? t`Doing science...`
: inputPlaceholder;
function cancelRequest() {
dispatch(cancelInflightAgentRequests());
}
const dispatch = useSdkDispatch();
useEffect(() => {
dispatch(resetConversationId());
}, [dispatch]);
const isOngoingConversation = metabot.messages.length > 0;
const isPromptDefined = metabot.prompt.length > 0;
return (
<Box className={Styles.container} data-testid="metabot-chat">
<Flex
className={cx(
Styles.innerContainer,
metabot.isDoingScience && Styles.innerContainerLoading,
inputExpanded && Styles.innerContainerExpanded,
)}
>
<Flex
w="16px"
h="16px"
style={{
flexShrink: 0,
}}
justify="center"
align="center"
>
{metabot.isDoingScience ? (
<Loader size="sm" />
) : (
<Icon name="ai" c="var(--mb-color-brand)" size="1rem" />
)}
</Flex>
<Textarea
id="metabot-chat-input"
data-testid="metabot-chat-input"
w="100%"
autosize
minRows={1}
maxRows={4}
ref={metabot.promptInputRef}
autoFocus
value={metabot.prompt}
disabled={metabot.isDoingScience}
className={cx(
Styles.textarea,
inputExpanded && Styles.textareaExpanded,
metabot.isDoingScience && Styles.textareaLoading,
)}
placeholder={placeholder}
onChange={(e) => metabot.setPrompt(e.target.value)}
// @ts-expect-error - undocumented API for mantine Textarea - leverages the prop from react-textarea-autosize's TextareaAutosize component
onHeightChange={handleMaybeExpandInput}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) {
return;
}
if (e.key === "Enter") {
// prevent event from inserting new line + interacting with other content
e.preventDefault();
e.stopPropagation();
handleSubmitInput(metabot.prompt);
}
}}
/>
{metabot.isDoingScience && (
<UnstyledButton
h="1rem"
data-testid="metabot-cancel-request"
onClick={cancelRequest}
>
<Tooltip label={t`Stop generation`}>
<Icon name="stop" c="var(--mb-color-text-primary)" size="1rem" />
</Tooltip>
</UnstyledButton>
)}
{!metabot.isDoingScience && (
<>
{isOngoingConversation && !isPromptDefined && (
<UnstyledButton
h="1rem"
onClick={startNewConversation}
data-testid="metabot-new-conversation"
>
<Tooltip label={t`Start new chat`}>
<Icon
name="edit_document_outlined"
c="var(--mb-color-text-primary)"
size="1rem"
/>
</Tooltip>
</UnstyledButton>
)}
{isPromptDefined && (
<UnstyledButton
h="1rem"
onClick={resetInput}
data-testid="metabot-close-chat"
>
<Icon
name="close"
c="var(--mb-color-text-primary)"
size="1rem"
/>
</UnstyledButton>
)}
</>
)}
</Flex>
</Box>
);
};

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from "react";
import { Stack } from "metabase/ui";
import { Messages } from "metabase-enterprise/metabot/components/MetabotChat/MetabotChatMessage";
import { MetabotResetLongChatButton } from "metabase-enterprise/metabot/components/MetabotChat/MetabotResetLongChatButton";
import {
useMetabotAgent,
useMetabotChatHandlers,
} from "metabase-enterprise/metabot/hooks";
import { useMetabotReactions } from "metabase-enterprise/metabot/hooks/use-metabot-reactions";
export function MetabotChatHistory() {
const metabot = useMetabotAgent();
const { messages, errorMessages } = metabot;
const { handleRetryMessage } = useMetabotChatHandlers();
const { setNavigateToPath } = useMetabotReactions();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const hasMessages = messages.length > 0 || errorMessages.length > 0;
// Auto-scroll to bottom when new messages are received
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop =
scrollContainerRef.current.scrollHeight;
}
}, [messages.length, errorMessages.length, metabot.isDoingScience]);
return (
<Stack
ref={scrollContainerRef}
flex={1}
gap={0}
style={{ overflowY: "auto" }}
p="md"
>
{hasMessages ? (
<Messages
messages={messages}
errorMessages={errorMessages}
onRetryMessage={handleRetryMessage}
isDoingScience={metabot.isDoingScience}
showFeedbackButtons={false}
onInternalLinkClick={setNavigateToPath}
/>
) : null}
{metabot.isLongConversation && <MetabotResetLongChatButton />}
</Stack>
);
}

View File

@@ -0,0 +1,137 @@
import { t } from "ttag";
import { useSdkDispatch } from "embedding-sdk-bundle/store";
import {
Flex,
Icon,
Loader,
Menu,
Textarea,
Tooltip,
UnstyledButton,
} from "metabase/ui";
import {
useMetabotAgent,
useMetabotChatHandlers,
} from "metabase-enterprise/metabot/hooks";
import { cancelInflightAgentRequests } from "metabase-enterprise/metabot/state";
import S from "./MetabotQuestion.module.css";
export function MetabotChatInput() {
const metabot = useMetabotAgent();
const { handleSubmitInput, handleResetInput } = useMetabotChatHandlers();
const dispatch = useSdkDispatch();
const placeholder = metabot.isDoingScience
? t`Doing science...`
: t`Ask AI a question...`;
const cancelRequest = () => {
dispatch(cancelInflightAgentRequests());
};
const resetInput = () => {
handleResetInput();
};
const startNewConversation = () => {
metabot.resetConversation();
};
return (
<Flex
gap="xs"
px="md"
align="center"
justify="center"
style={{ borderTop: "1px solid var(--mb-color-border)" }}
>
<Flex justify="center" align="center" style={{ flexShrink: 0 }}>
{metabot.isDoingScience ? (
<Loader size="sm" />
) : (
<Icon name="ai" c="var(--mb-color-brand)" size="1rem" />
)}
</Flex>
<Textarea
id="metabot-chat-input"
data-testid="metabot-chat-input"
w="100%"
pb="0.2rem"
pt="0.6rem"
autosize
minRows={1}
maxRows={4}
ref={metabot.promptInputRef}
autoFocus
value={metabot.prompt}
disabled={metabot.isDoingScience}
placeholder={placeholder}
onChange={(e) => metabot.setPrompt(e.target.value)}
classNames={{ input: S.chatInput }}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) {
return;
}
const isModifiedKeyPress =
e.shiftKey || e.ctrlKey || e.metaKey || e.altKey;
// Prevent event from inserting new line and interacting with other content
if (e.key === "Enter" && !isModifiedKeyPress) {
e.preventDefault();
e.stopPropagation();
handleSubmitInput(metabot.prompt);
}
}}
/>
<Flex align="center" justify="center" gap="xs">
{metabot.isDoingScience && (
<UnstyledButton
data-testid="metabot-cancel-request"
onClick={cancelRequest}
className={S.chatButton}
>
<Tooltip label={t`Stop generation`}>
<Icon name="stop" c="var(--mb-color-text-secondary)" />
</Tooltip>
</UnstyledButton>
)}
{!metabot.isDoingScience && metabot.prompt.length > 0 && (
<UnstyledButton
onClick={resetInput}
data-testid="metabot-close-chat"
className={S.chatButton}
>
<Icon name="close" c="var(--mb-color-text-secondary)" />
</UnstyledButton>
)}
{/* Overflow menu - only shown in stacked layout and auto layout mobile */}
<Menu position="top-end" withinPortal>
<Menu.Target>
<UnstyledButton
data-testid="metabot-overflow-button"
className={S.stackedOverflowButton}
>
<Icon name="ellipsis" c="var(--mb-color-text-secondary)" />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Icon name="edit_document_outlined" size="1rem" />}
onClick={startNewConversation}
>
{t`Start new chat`}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Flex>
</Flex>
);
}

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