Compare commits

...

121 Commits

Author SHA1 Message Date
Nicola Mometto
5a43b2d35f further branch 2026-02-03 14:19:38 +00:00
Nicola Mometto
832f8ac44d wip: force run 2026-02-03 13:47:51 +00:00
Nicola Mometto
88bdc6c06c test other branch 2026-02-03 13:47:41 +00:00
Nicola Mometto
bdfc24938c wip: test pyarrow parsing 2026-01-28 09:54:26 +00:00
Romeo Van Snick
123f2dafd2 MBQL lib helpers for e2e tests (#68278)
* Rename file containing jsx to .jsx

* Add e2e helpers to build metadata provider

* Split up metadata helpers

* WIP

* Add more direct createTestQuestion helper

* Replace test

* Build cljs for tests

* Export type

* Only require entities in the state for getMetadata

* Just pass state to getMetadata

* Move EntitiesSchema to metabase/schema

* Wrap mbql e2e helpers in promise

* Split off getMetadataWithoutSettings

* Use getMetadataWithoutSettings to get metadata from state

* Build entities state manually

* Simplify wrapper

* Fix metadataProvider

* Add createTestJsQuery wrapper for cypress

* Use plain promises and fetch

* Allow passing GetMetadataOpts to createTest(Native)JsQuery directly

* Improve createTestQuestion types

* Make use of createTestJsQuery in test

* Reduce nest in createTestQuestion

* Use simplified createTestQuestion

* Split up mbql helpers

* Fix card metadata call

* Use correct card endpoint

* Pass missing options

* Convert another more complex test to createTestQuestion

* Fix types

* Use static ids for defaults to avoid import loop

* Fetch more tables by default

* Build cljs in embedding tests

* Build cljs in component tests

* Prepare backend env

* Add plugin so we can avoid building CLJS everywher

* Avoid building cljs where not needed

* Fix types

* Rename query helpers

* Split up helpers

* Compile cljs in stress tests
2026-01-26 19:54:16 +11:00
metabase-bot[bot]
16504fd3c8 [chore] Update E2E test timings with recent data (#68665)
Update E2E test timings with averaged data

Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).

🤖 Generated automatically by update-e2e-timings workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
2026-01-26 21:50:07 +13:00
Romeo Van Snick
997b8d898e Respect abbreviate date style and be defensive around missing settings (#68661)
* Respect date_abbreviate when formatting date

* Respect abbreviate setting in month format too

* Add unit tests for abbreviated formats

* Add unit test for empty formatting settings

* Add repro for metabase#68574

* Add formatting settings unit tests
2026-01-26 15:50:42 +11:00
Alexander Polyankin
ceb518ab4b Dependency diagnostics - update the url, reset page on filter change, fix visibility checks (#68650) 2026-01-25 21:53:08 -05:00
Fraser
0ed6c4deed Link Claude Bugfix PR to Linear issue (#67710) 2026-01-26 10:44:24 +13:00
Sloan Sparger
6c09fca355 Fix NativeQueryEditor's action buttons (#68638)
fix position of buttons for native query editor
2026-01-24 20:34:41 +00:00
Aleksandr Lesnenko
049526c61c Cleanup (#68648)
cleanup
2026-01-24 09:40:18 -06:00
William
a799178420 Python transforms have dependencies (#68572)
The existing transform dependencies code only worked with query-based transforms (mbql or native).  This extends that
code to work with python transforms as well.
2026-01-24 02:34:24 -06:00
Maksym Yakubych
f7b82d5c54 fix: word wrap overflows row when reordering in dashboard table card (#68566) 2026-01-24 00:37:00 -03:00
Anderson
a51d1179d3 Disallow editing transforms when remote sync is read-only (#68380)
* Read-only: Adjust transform header menu options / Remove edit definition button / Run tab updates

* Make Settings tab read-only

* fix remote sync setup in e2e test

* Do not allow creating a new transform

* Make Python library not editable

* Hide edit definition in mbql transform view page

* Fix e2e

* Group e2e tests to improve performance
2026-01-24 01:52:27 +00:00
metamben
cbed2e45a8 Follow up on QUE-2999: move filtering to the database (#68635) 2026-01-23 23:45:05 +00:00
Timothy S. Dean
418d7a0317 Tweak data loading behavior for a test after MySQL 9.6.0 release (#68634)
Closes DEV-1289

### Description

Within the last day or two, `metabase.query-processor-test.remapping-test/self-referencing-test` began failing on MySQL Latest. I hypothesize this has to do with the [latest release](https://dev.mysql.com/doc/relnotes/mysql/9.6/en/news-9-6-0.html) fixing an unknown bug around batch inserts in InnoDB and the deprecation of a recusive [sic] fk-related flag.

The error is thrown not in the test conditions, but in the test setup -- MySQL apparently now checks foreign key constraints row-per-row even on a batch insert.

This adds a flag that the test can set which changes the test data-loading behavior to disable the FK check before insertion and re-enable it after.
2026-01-23 15:56:20 -07:00
Gabriel Moreno
3bce633480 Library empty state: display content in the page body (#68628)
* feat: create library empty state

* refactor: use new designs

* refactor: fix library creation

* fix: paper shadow

* fix: e2e
2026-01-23 18:49:35 -04:00
Gabriel Moreno
48327532c5 More Data Studio UX improvements & QoL fixes (#68546)
* fix(UXW-2752): remove unwanted padding on data structure fields tab group

* fix(UXW-2707): remove double border on job transforms section

* feat(UXW-2718): add horizontal layout for schedule controls

* feat(UXW-2709): fix spacing in the segments empty state

* fix(UXW-2780): long text overflow in user input

* fix(UXW-2710): set proper placeholder text on empty python library first load

* feat(UXW-2767): rename 'New collection' to 'Transform folder'

* refactor: blow grid & use `Ellipsified` instead
2026-01-23 18:03:17 -04:00
Anderson
26f71cfeb5 Fix SQL editor scrolling on Transform pages (#68107)
* Fix SQL editor scrolling on Transform pages

* Fix e2e test for question editor

* Fix python transform scrolling

* Fix lint

* Fix view-only transform content height
2026-01-23 18:42:24 -03:00
Braden Shepherdson
3d4e44b776 [deps] After a sync, re-analyze all dependents of updated tables (#68621)
Closes QUE-2984.

Adds an event handler for when a DWH sync is completed, which checks all its tables and fields for
their latest update times, and re-analyzes all cards which depend on tables updated since the last analysis.
2026-01-23 21:01:41 +00:00
Brad Anderson
86f3e524ba Fix UXW-2759 - Snippet edits in data studio are not saved (#68565) 2026-01-23 15:29:42 -05:00
Nathan Voxland
056d61238e If a dashboard is back to just one tab, auto-assign all cards to that tab (#68201) 2026-01-23 14:22:12 -06:00
Maksym Yakubych
926610da14 fix: custom Logo isn't loaded properly on User Invite Email (#68476) 2026-01-23 11:44:43 -08:00
Alexander Polyankin
75251f933a Fix text wrapping in dependencies (#68615) 2026-01-23 19:28:48 +00:00
Nemanja Glumac
d959d54aec Remove cache cleanup CI action (#68521) 2026-01-23 20:25:23 +01:00
Edward Paget
0933b7378f feat: support remote-syncing measures (#68392)
* feat: support remote-syncing measures

Add remote-sync support for measures, following the same pattern as segments:

- Add :model/Measure spec to remote-sync-specs with hybrid identity,
  parent-table eligibility, and event tracking
- Update measure permissions to check remote-sync/table-editable? for
  can-write? and can-create? (replacing superuser-only derive)
- Add batched hydration for :can_write on measures
- Add measures to serdes/descendants for Table so they export with parent
- Add tests for measure import/export tracking in impl_test.clj

On the Frontend creates a new data-driven approach to setting up the
middleware listener for changes that cause us to refresh dirty-state.

* fixup! typecheck
2026-01-23 13:17:01 -06:00
Edward Paget
e3b200b319 fix: flakey ec keypair test (#68591)
* fix: flakey ec keypair test

* fixup! do crypto in a delay
2026-01-23 13:15:00 -06:00
Edward Paget
fb9c0e4499 fix: derive events remote-sync needs in oss code (#68590) 2026-01-23 13:14:51 -06:00
Nemanja Glumac
40522b6cba Remove unused lodash types (#68593) 2026-01-23 19:26:18 +01:00
metamben
b6edd15de2 Stop reporting errors on archived or inaccessible entities (#68613)
Closes QUE-2999
2026-01-23 12:52:32 -05:00
lbrdnk
16042f45c7 Use macaw options, add parsed-query wrapped (#68466)
* Hoist macaw impl from query analyzer

* Use macaw-opts in bigquery module

* Use macaw-options in sqlserver

* Use macaw-options in sql driver

* Remove require

* Comment

* Test

* Update test

* Update macaw version

* Best effort support for bigquery

* Make test driver specific

* Remove final-not-reserved

* Not allowed on clickhouse only

* Clickhouse exception test

* best effort opts on api

* Remove final-non-reserved

* Remove requires

* driver.u/parsed-query

* Use parsed-query

* parsed-query

* ch test

* throw

* Lint against incorrect Macaw usage (#68568)

* Remove now unused function

* Shutdown loud exceptions for now

* Shutdown ch test

* Update test

---------

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-01-23 18:37:06 +01:00
Riley Thompson
b28ab0ec1d Revert ClickHouse from 0.9.5 to 0.8.4 (#68502)
* revert to 0.8.4

* disable test
2026-01-23 17:18:22 +00:00
github-actions[bot]
add8906504 Fix: Keyboard navigation annoyance in command palette (#67317)
Co-authored-by: Claude Code[bot] <noreply@anthropic.com>
Co-authored-by: Brad Anderson <brad@metabase.com>
2026-01-23 11:33:33 -05:00
dpsutton
3345365546 handle empty error message (#68581)
Fixes https://github.com/metabase/metabase/issues/68578

This issue is not about the stack overflow per-se but blowing up on a
string includes:

```javascript
{
  "app": "metabase",
  "time": "2026-01-22T12:08:53.660601485Z",
  "stream": "stderr",
  "app_version": "v1.57.10-X01",
  "message": "Exception in thread \"Thread-41\" java.lang.NullPointerException: Cannot invoke \"Object.toString()\" because \"s\" is null
at clojure.string$includes_QMARK_.invokeStatic(string.clj:377)
at clojure.string$includes_QMARK_.invoke(string.clj:373)
at metabase.app_db.cluster_lock$retryable_QMARK_.invokeStatic(cluster_lock.clj:27)
at metabase.app_db.cluster_lock$retryable_QMARK_.invoke(cluster_lock.clj:19)
at metabase.app_db.cluster_lock$do_with_cluster_lock.invokeStatic(cluster_lock.clj:87)
at metabase.app_db.cluster_lock$do_with_cluster_lock.invoke(cluster_lock.clj:65)
at metabase.app_db.cluster_lock$do_with_cluster_lock.invokeStatic(cluster_lock.clj:79)
at metabase.app_db.cluster_lock$do_with_cluster_lock.invoke(cluster_lock.clj:65)
at metabase.search.task.search_index$init_BANG_.invokeStatic(search_index.clj:36)
at metabase.search.task.search_index$init_BANG_.invoke(search_index.clj:32)
at clojure.lang.AFn.run(AFn.java:22)
at java.base/java.lang.Thread.run(Unknown Source)",
  "namespace": "hosting-eb9b3875e12d465a",
  "container": "metabase",
  "cluster": "metabase-1",
  "_p": "F"
}
```

The error message itself comes from a stackoverflow which perhaps does
not have a message? Either way, it's possible for this to be missing so
be a bit defensive
2026-01-23 10:04:56 -06:00
Sébastien
862dc50c64 Fix ChartTypeDropdown overflowing (#68455)
* Forcing overflow policy on ChartTypeDropdown

* tpyo

* refactoring to enable testing this with loki

* fixed story

* loki

* screenshot

* removed useless unit test
2026-01-23 16:08:37 +01:00
Nicola Mometto
0941091421 ensure common.py library is always available (#68603)
* fix: ensure common python library is always defined

* chore: e2e test

* fix: empty rollback

* fix: empty source

* fix: text
2026-01-23 15:01:18 +00:00
Alexander Polyankin
0bd69823a4 Show view_count for models and metrics in dependencies (#68588) 2026-01-23 17:42:57 +03:00
Alexander Polyankin
6a01533ca7 Use MBQL lib for matching field refs in ValueSourceModal in parameters (#68431) 2026-01-23 17:42:06 +03:00
Nemanja Glumac
7d18a1b5a6 Unset the premium token for E2E tests (#68533) 2026-01-23 13:54:21 +01:00
github-automation-metabase
0bf7e41984 Auto-update documentation for master (#68589)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-01-23 07:47:03 -05:00
Sébastien
95a25e4a3e fix proper bg color for embedded dashboards with custom theme (#68539) 2026-01-23 13:00:22 +01:00
Dan Stone
a2c4eef9de Revert the revert of column validation for incremental transforms (#68460)
Filter extract-columns endpoint to only return numeric and temporal columns
The extract-columns-from-query function is used to populate the incremental
filter dropdown for native SQL transforms. Previously it returned all columns
without filtering on type, leading to undefined behaviour when unsupported
column types were selected.

This change filters the results to only include columns with types supported
for incremental checkpointing:
- Temporal types (timestamp, timestamp with timezone)
- Numeric types (integer, float, decimal)

Text, boolean, and other unsupported types are now filtered out, matching
the behaviour of MBQL and Python transforms which already filter columns
client-side.

Updated documentation to clarify this endpoint is specifically for incremental
transform checkpoint column selection.

Also validates that transform POST/PUT conform to this rule and you are unable to update the transform to have
a filter column of an unsupported type.

Reverts the revert of https://github.com/metabase/metabase/pull/67686
2026-01-23 11:24:09 +00:00
Chris Truter
2f81937643 Fix data format for TODOs in style guides (#68598) 2026-01-23 13:13:30 +02:00
Sloan Sparger
48bf58b162 Cleanup from #68433 (#68481)
fix ai feedback that came after PR close :'/
2026-01-23 02:33:48 +00:00
Egor Grushin
761a7cf01e GDGT 1533 improve discoverability of python library in transforms UI (#68523) 2026-01-23 00:55:37 +00:00
Timothy S. Dean
8a76f71003 Remove find-column-for-name (#68382)
Remove find-column-for-name
2026-01-22 16:06:41 -07:00
Maksym Yakubych
fd90d13ae3 fix: custom brand color not consistently applied on Enterprise UI elements (#68484) 2026-01-22 22:56:00 +00:00
Maz Ameli
a62aab3f1b Rename Data Studio Library +New menu action labels (#68162)
* Rename Data Studio Library +New menu action labels

Update the menu item labels in the Data Studio Library's +New button:
- "Publish a table" → "Published table"
- "New snippet" → "Snippet"
- "New snippet folder" → "Snippet folder"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* prettier

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:39:54 +00:00
metamben
bafd49ee17 Add options to the /graph/dependents endpoint (#68573) 2026-01-22 21:58:19 +00:00
Edward Paget
1f0b461d6f feat: support remote sync transforms in FE (#68306)
* feat: support remote sync transforms in FE

1. Updates the DataStudioLayout to show transform dirty state

   We get the orange dot next to Transforms if transforms or transform
   collections are dirty.

2. Update the all changes view to include transforms

   Also includes their collection heirarchy if the transforms sync
   setting is enabled

3. Add the transforms sync setting

   To both the remote sync admin page and the Set up git sync modal in
   the data studio

* fixup! refresh transforms after sync

* fixup! data studio test feedback

* fixup! refactor failing test to troubleshoot debugging
2026-01-22 14:33:00 -06:00
metamben
1712274b85 Normalize transforms metadata (#68490) 2026-01-22 23:22:39 +03:00
Timofey Kachalov
ea203102e6 Don't show CORS settings in OSS/Starter editions (#68564) 2026-01-23 00:14:05 +04:00
Edward Paget
3c72591b17 feat: add Slack Connect SSO provider (#67955)
* feat: add Slack Connect SSO provider

Add enterprise Slack Connect SSO integration using the OIDC infrastructure:

- Slack Connect provider: OAuth2/OIDC flow for Slack workspace authentication
- Settings: configuration options for Slack client ID, secret, and auth modes
- User provisioning: automatic user creation from Slack identity
- API endpoints: SSO initiation and callback handling
- Analytics: usage tracking for Slack Connect feature

Slack Connect allows users to authenticate via their Slack workspace,
with support for both SSO-only and link-account authentication modes.

* fix: session reset after auth

* fixup! avoid email clash

* fixup! wrong value in sso settings

* fixup! cleanup tests

* fixup! fix test

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
2026-01-22 10:57:12 -08:00
github-automation-metabase
9a19a5e4f0 [chore] Update llms.txt for master (#68561)
Update llms.txt and llms-full.txt (master)

Regenerated llms.txt to include all markdown files from the docs/ directory.
Also generated llms-full.txt files for the embedding section.

🤖 Generated automatically by llms-txt-generate workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
2026-01-22 18:20:04 +00:00
Sloan Sparger
f387d4aca4 [Metabot] Add close button back to data studio sidebar (#68487)
allow metabot to be closed in data studio
2026-01-22 11:33:21 -06:00
github-automation-metabase
1bb6f6a74a Update translations 2026-01-22 (#68557)
* Update translation sources 2026-01-22

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-22 17:19:01 +00:00
Nicolò Pretto
2ad4292465 fix(embed-homepage): rename static embed to guest embed (#68338) 2026-01-22 16:37:02 +00:00
github-automation-metabase
37c3758748 Auto-update documentation for master (#68524)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-01-22 09:26:48 -05:00
Jeff Bruemmer
ae9cad5c58 docs - note on ee vs oss path (#68210) 2026-01-22 09:09:05 -05:00
John Swanson
bd14e14974 Reenable deactivated tenants when autoprovisioning (#67947)
Previously even when autoprovisioning was on, you could not log into a
deactivated tenant (either as a new user or an existing user with that
tenant).

This changes the behavior to be more consistent. Just as a deactivated
user can log in when provisioning is on (and their user gets
reactivated), a deactivated tenant's users can still log in, but doing
so reactivates the tenant.
2026-01-22 05:10:23 -08:00
Jakub Chodorowicz
85319217cd Gdgt 1273 add snowplow events to bulk metadata features (#68409)
* add data studio library filtering events

* add bulk properties and unpublish event

* add bulk sync events

* add search event

* add assertions on events in e2e tests

* add search debounce

* remove unncessery comment

* update event names

* track filter searches with data_studio_table_picker_search_performed

* refactor tracking function

* guard and log event only for ConcreteTableId

* log search when started fetching

* fix events not fired

* fix event not fired on first search
2026-01-22 12:30:26 +01:00
Nicola Mometto
240acb4782 add started-at filter to task runs page (#68279)
* WIP: add started-at filter

* fix: kondo

* fix tests

* GDGT-1520 add a started at filter

* GDGT-1520 add a comment

* GDGT-1520 fix typing issues

* GDGT-1520 addressing PR comments

* GDGT-1520 addressing PR comments

* GDGT-1520 fix a tests after changing a text

---------

Co-authored-by: Egor Grushin <egor@metabase.com>
2026-01-22 09:49:15 +00:00
Jakub Chodorowicz
e6bc40c223 add analytics skill (#68419) 2026-01-22 09:36:42 +01:00
bryan
76fcb4d573 Fix transforms module to trigger driver tests + promote pg (#68432) 2026-01-21 16:30:15 -07:00
Arkadi Yaroslavtsev
a92317dd27 Add Anchor Links to Documents (#67940)
* Add AnchorLinkButton component for document anchor links

- Add AnchorLinkButton component that copies URL to clipboard on click
- Shows 'Copied!' tooltip feedback using Mantine useClipboard hook
- Follows same pattern as CommentsButton for consistent styling
- Add unit tests for the component

* Add AnchorLinkMenu component for left-side anchor link hover

- Add AnchorLinkMenu floating menu for document blocks
- Uses createPortal for positioning (same pattern as CommentsMenu)
- Positioned on left side of blocks using floating-ui
- Shows/hides based on hover state via show prop
- Add unit tests for the component

* Add AnchorLinkMenu to document node views

- Add documentWithAnchor URL helper to generate shareable anchor URLs
- Update Heading, Paragraph, and Blockquote node views to render AnchorLinkMenu
- AnchorLinkMenu appears on the left side on hover
- CommentsMenu on the right side remains unchanged
- Each node view now uses two useFloating instances for positioning

* Add scroll-to-anchor for document URLs with hash

- Add E2E tests for anchor links functionality
- Implement scroll-to-anchor when navigating to document with #blockId hash
- Tests cover: hover shows anchor icon, copy to clipboard, scroll to block
- Regression test ensures CommentsMenu still works on right side

* Fix scroll-to-anchor dependency arrays for navigation edge cases

- Add documentId to scroll effect dependencies to re-run when navigating
  between documents with the same anchor hash
- Add location.hash to reset effect dependencies to properly reset flag
  when navigating via back button or changing anchors

* Fix accidental logo replacement and E2E blockquote test

- Restore original Metabase logo SVG (was replaced with red star debug graphic)
- Fix E2E test: use find('blockquote') instead of findByRole('blockquote')
  since native blockquote elements have no implicit ARIA role

* Fix scroll-to-anchor reliability and empty line behavior

- Implement retry mechanism for scroll-to-anchor to ensure target element exists
- Add highlight animation when scrolling to anchor
- Hide anchor link and comment menus on empty lines (Heading, Paragraph, Blockquote)

* Fix selector mismatch for scroll-to-anchor

- Add data-node-id attribute to NodeViewWrappers in Heading, Paragraph, and Blockquote
  extensions so they can be queried by DocumentPage
- Update DocumentPage to query using [data-node-id] selector instead of internal _id attribute
- Remove unused ID_ATTRIBUTE_NAME import in DocumentPage

* Fix highlight animation color variable

- Replace non-existent --mb-color-brand-alpha-25 with --mb-color-brand-light
- Ensure highlight animation is visible when scrolling to anchor

* added anchor links to lists and code blocks. some bugs fixes.

* make sure scroll-to-anchor observer is correctly initialized

- Refactor scroll-to-anchor logic to ensure smoother navigation
- Optimize highlight animation visibility during scrolling
- Adjust anchor link behavior on empty lines for better user experience

* small fixes

* clean up

* fixes and cleanup

* e2e test fixes

* fixed e2e test

* Leave anchor links only for header blocks

* Fix alignment

* Review fixes

* Fix unit tests

---------

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2026-01-22 02:03:04 +03:00
bryan
087a317f70 Fix ci-report output formatting issues (#68505) 2026-01-21 15:50:57 -07:00
Denis Berezin
c63fd746d8 Uxw 2633 copy cells from table (#68429)
* UXW-2633 fix sorted table values copy-paste

* Add e2e test
2026-01-22 01:24:12 +03:00
dpsutton
f29a446aab fix materialized views test (#68509)
https://github.com/metabase/metabase/pull/68190 did:

> Treats a 0 value of n_live_tup as NULL, due to confusion reported when
> auto analyze thresholds have not been met on a table.

I think what's happening is this is trying to suss out "i don't know"
and not give misleading information. When there are no stats pg would
report there are 0 rows rather than saying i don't know. So we toss away
these unreliable 0 count estimations.

This test flakes 7% of the time. Our hypothesis is that stats are empty
7% of the time so this row count estimation now gets removed.

By default the schema is `:estimated_row_count (mt/malli=? [:maybe
:int])` which was being clobbered with
`{:estimated_row_count (mt/malli=? int?)}` So the fix is to just not
clobber
2026-01-21 16:07:45 -06:00
Edward Paget
d26bf4e454 feat: support transforms in remote sync (#68255)
* feat: support transforms in remote sync

1. Serialize Transforms as Collection Items

   Transforms were previously serialized as a top-level item, but this
   moves them to be serialized as a child of the collection they are
   member of. This also fixes an error in their serdes/dependencies
   implementation where it did not include the parent collection leading
   to deserialization issues.

2. Enforce Remote-Sync write rules

   Adds implementations of the model interface permissions methods for
   transforms that enforce the can-write? = false and can-create? =
   false rules when remote-sync is in read-only mode.

3. Add a setting to control whether transforms are synced

   Transforms are globally synced not synced via collections, so this
   adds a new boolean setting that controls whether they are synced on
   instances in read-write mode. Like all other content transforms are
   always synced from the remote source if the instance is in read-only
   mode.

4. Add a new spec-based configuration system for models

   Models we want to make remote-syncable have a complicated, but still
   data-driven system to allow you to specify their remote-sync behavior
   and have that automatically setup in a single place the
   `metabase-enterprise.remote-sync.spec` namespace.

* fixup! test with wrong folder

* fixup! not handling archived transform collections

* fixup! clean up aliases
2026-01-21 15:29:27 -06:00
Denis Berezin
7aa18ae448 Fix flaky test that uses readonly db that lacks permissions (#68417) 2026-01-22 00:15:27 +03:00
Timothy S. Dean
97da3d7c7b Migration follow-up fix to #68100 (#68498)
Move a migration from the v59 file to the v56 file, as we intend to backport it that far.
2026-01-21 14:15:12 -07:00
Aleksandr Lesnenko
10e8a68cf5 Data Analysts user group (#67770)
* basic analyst role

* analyst role

* Backend for data analyst role

* Fixup frontend to work with Backend

The dedicated analyst endpoints have been removed:
- `GET /api/user/analysts` -> Use `GET /api/user?is_data_analyst=true`
- `POST /api/user/analysts/:id` -> Use `PUT /api/user/:id` with `{ is_data_analyst: true }`
- `DELETE /api/user/analysts/:id` -> Use `PUT /api/user/:id` with `{ is_data_analyst: false }`

These hooks no longer exist:
- useListAnalystsQuery
- useAddAnalystMutation
- useRemoveAnalystMutation

New Patterns:

Listing analysts:
```
// Before
const { data } = useListAnalystsQuery();

// After
const { data } = useListUsersQuery({ is_data_analyst: true });
```

Adding an analyst:
```
// Before
await addAnalyst(userId).unwrap();

// After
await updateUser({ id: userId, is_data_analyst: true }).unwrap();
```

Removing an analyst:
```
// Before
await removeAnalyst(userId).unwrap();

// After
await updateUser({ id: userId, is_data_analyst: false }).unwrap();
```

Type Changes:

The field is_analyst has been renamed to is_data_analyst everywhere:

- User.is_data_analyst (was is_analyst)
- UpdateUserRequest.is_data_analyst (was is_analyst)
- ListUsersRequest.is_data_analyst (new field for filtering)

* kondo

* circular dep

* fix backend not compiling

* use the actual role

* analyst management, glossary access for admins

* various data studio access

* access to library

* drop custom analyst seats management ui

* automatic perms group

* fix tests

* specs

* permissions

* fix lints, add migration rollbacks

* data analysts can ALWAYS view table metadata

implemented for:

- search
- `table/can-read?`

* Also remove `can_access_data_model`

* Revert "Also remove `can_access_data_model`"

This reverts commit 03af432ccb.

* fix more tests

* more tests

* snapshots

* remove redundant checks

* everyone can see

* hide data analyst group for OSS

* Make data analysts a normal group on OSS

* Automatically provide data analysts with perms

Data analysts automatically get manage-table-metadata permissions on
every table/database.

* only disable setting some perms for data analysts

* kondo

* oops, one more kondo

* test fixes

* specs, fixes

* more specs, fixes

* cleanup

* more test fixes

* fix module config

* ci

* doh, fix test

* more test whack-a-mole

* move data analyst group to the top of perms views

* review

* ci

* Turn DA into a regular group when feature missing

instead of just on OSS.

The goal is:
- OSS: a previously existing Data Analysts group is a regular group with
configurable permissions. The real Data Analysts group is invisible and
has no members.
- EE without the token: same as above
- EE with the token: the real Data Analysts group is visible.

* oops, remove unnecessary require

* another one

* fix membership API

* fix a couple more tests

* macro gets a !

* fix `fetch-groups-test`

* ci

* ci

* ci

* ci

* fix collection perm in migration

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
2026-01-21 12:43:35 -08:00
Dan Meyer
209a34b102 ci: Enable fail-fast for BigQuery and Snowflake driver tests (#68199)
Add :fail-fast? true to BigQuery and Snowflake test-args, matching the
existing Redshift configuration. This stops test execution on first
failure, saving CI time and resources.
2026-01-21 20:27:03 +00:00
William
a6df84c136 Bump toucan2 version to fix dashboards sometimes failing to load (#68494)
If the first table mentioned by a dashboard has no fields (presumably because the table and its field are all
inactive), /api/dashboard/<id>/query_metadata would fail due to a toucan2 bug.  This bumps toucan2 to fix the bug and
adds a test to verify that this case now works.
2026-01-21 20:18:30 +00:00
Riley Thompson
adb9a5cbf4 Bump dependency analysis version (#68488)
bump dependency analysis version
2026-01-21 19:08:45 +00:00
Phoomparin Mano
d491eb6d51 Apply unified theme object to main app and modular embedding (#68169)
Co-authored-by: Mahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev>
2026-01-22 01:30:13 +07:00
Cam Saul
0f20f4d2f0 Deduplicate field refs by ignoring :source-field-name unless it matters (#68166)
* Tentative fix for #67808 -- deduplicate by ignoring :source-field-name if possible

* Make sure `:fields` remains a vector

* Add note

* cljfmt
2026-01-21 11:13:14 -06:00
Maksym Yakubych
9304e6cb6e fix: page hangs without a loader when saving a PDF (#68385) 2026-01-21 13:37:44 -03:00
Riley Thompson
21f043d697 Return upstream breaking entities for dependencies (#68282)
* initial work

* fix test

* fix another test

* source entity malli schema

* clean up and add test

* add tests

* clean up

* fix tests

* drop finding_details column

* fix analysis version

* Update enterprise/backend/src/metabase_enterprise/dependencies/models/analysis_finding.clj

Co-authored-by: Timothy S. Dean <timothy.dean@metabase.com>

* use transaction

* use node-errors refactor suggestion

* use maybe-resolve-join

* add schemas

* add comment

* kondo

* fix test

* initial changes

* remove validate/ prefix

* replace name/message with detail in dependents_errors

* fix test

* kondo

* typo

* Clean up

* add back card types

* remove unused

* add back card types comment

* small adjustment

* Fixes

* Add links

* reuse existing query

* UI

* use detail

* clean up tests

* UI

* UI

* UI

* improve tests

* initial /dependents broken filtering

* UI

* UI

* UI

* UI

* UI

* UI

* UI

* UI

* return full analysis finding error from api

* accept multiple dependent_types and dependent_card_types

* sort by dependents errors

* fix dependents broken parameter test

* fix

* fixes

* fix sorting

* Fix

* fixes

* fixes

* fixes

* fix

* tests

* fixes

* fix

* fixes

* fixes

* fixes

* fixes

* fixes

* add transforms

* fixes

* fix

* Add views

* fixes

* fixes

* basic e2e test

* fixes

* tests

* tests

* tests

* tests

* tests

* fixes

* fixes

* fix mysql distinct counting

* fixes

* fixes

* fix count query

* handle emtpy error detail

* fix i18n parser

* initial pr comments

* refactor dependency-items-args

* address comments

* small changes

* fix flake

---------

Co-authored-by: Timothy S. Dean <timothy.dean@metabase.com>
Co-authored-by: Alexander Polyankin <alexander.polyankin@metabase.com>
2026-01-21 11:26:10 -05:00
Sloan Sparger
d6bb46208f [Metabot] Send transforms_codegen profile on transforms pages again (#68433) 2026-01-21 09:09:12 -06:00
Egor Grushin
e2f7cc9d86 GDGT-1516 add datetime formatting to Task Run Monitoring features (#68427) 2026-01-21 15:09:03 +00:00
Oleksandr Solovyov
0880e7297d fix incremental transform tests (#68470) 2026-01-21 17:04:54 +02:00
metamben
4f23bc973f Prevent exception in cleanup masking the original exception (#68371) 2026-01-21 14:49:43 +00:00
Anderson
1afabc3d01 Allow editing segments and measures when remote sync is read-only and table is unpublished (#68326)
* Still allow editing segments when table is not published

* Allow editing on measures when table is not published
2026-01-21 11:45:19 -03:00
github-automation-metabase
cdf5111a0d Auto-update documentation for master (#68447)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-01-21 09:13:34 -05:00
Timofey Kachalov
70cf127afd Remove linking between static and simple embedding settings when toggling guest embeds in Admin UI (#68231) 2026-01-21 16:20:30 +04:00
Oleksandr Solovyov
ac421d0a6d transforms: pass full object instead of re-querying db internally (#68456) 2026-01-21 11:45:23 +00:00
Dan Stone
9f5b23ecdd Custom ref schema that reuses the cache (#68379)
Add ref schema caching

Introduces a custom :ref IntoSchema that permits
reuse of Schema objects for references when safe to do so.

Early tests indicate a dramatic reduction in heap retention, after
loading a single dashboard, I see a 10x resident heap reduction from ~300MB to ~30MB.

It should also act as knot tie / runtime combinatorics limiter, so we should not see 'leaky' growth at runtime.

More work could be done as there are still _some_ gaps here.
- Bare pointers (keywords) can still lead to exponential allocation
- We do not cache when :documentation is present on the ref
  (as it would specialise the Schema object delivered for that specific reference)
2026-01-21 10:30:04 +00:00
Sébastien
f8551c9800 Fixed no-literal-metabase-strings eslint rule for export * from './MetabaseThing'; (#68411)
fixed eslint custom rule
2026-01-21 10:42:55 +01:00
Kamil Mielnik
4befb6089f Fix - It's not possible to continue editing a bad transform (#68415) 2026-01-21 09:08:12 +00:00
Romeo Van Snick
c902d22458 Contain layout for notebook preview (#68448)
* Contain layout for notebook preview

* Add repro for metabase#67903
2026-01-21 19:14:00 +11:00
Romeo Van Snick
7451ffcd93 Set max height to native editor and some other refactors (#67896)
* Desrtucture all this.props

* Convert to function component

* Remove the wrapper

* Forward ref in topbar

* Limit resize height

* Pass availableHeight to native query editor

* Move var to constants

* Add more padding

* Rename viewHeight to availableHeight

* Handle unset availableHeight

* Add availableHeight to QueryEditor

* Calculate availble height above the fold

* Resize editor when available size shrinks

* Remove layer of nesting

* Rename calc to get

* Pass availableHeight to sql editor in modal

* Remove resizableBoxProps prop

* Do not resize when closing editor

* Use runQuery from props when availabe

* Sort props and remove unused props

* Clean up setIsNativeEditorOpen

* Remove dispatch from NativeQueryEditor completely

* Move state change out of useEffect

* Pull out resizable box into separate component

* Simplify availableHeight prop

* Move resizable logic into separate component

* Clean up code

* Handle availableHeight being 0 initially

* Make minHeight take preference

* Avoid setting height to Infinity

* Add repro for metabase#59075

* Fix e2e test

* Do not use default export

* Add type to NativeQueryEditor

* Remove unused databases loader

* Remove unused ExplicitSize wrapper

* Remove enity loader wrappers

* Flatten props

* Fix setDatabaseQuery type

* Fix types

* Add className to NativeQueryEditor

* Fix types

* Pull out handler

* Manually update metadata when updating snippet

* Preserve sidebar state if need be

* Fix e2e test

* Fix mock exports

* Fix e2e test

* Do not overwrite availableHeight

* Remove viewHeight prop

* Increase limit

* Fix broken test

* Remove unneeded useHeightAboveFold helper
2026-01-21 06:47:32 +00:00
dpsutton
410471a4d0 Revert transforms changes recently (#68446)
* Revert "Filter incremental transform column listing to supported types only (#67686)"

This reverts commit 735b98d6f8.

* trigger driver changes
2026-01-21 03:16:13 +00:00
Nemanja Glumac
a2b3490355 Run codespell on schedule and auto-fix typos (#68444) 2026-01-21 00:14:41 +00:00
bryan
3b8e1ecaa4 Add mage ci-report command for CI failure analysis (#68319) 2026-01-20 22:23:40 +00:00
Maksym Yakubych
0176c399f9 fix: "Connect to Slack" CTA remains visible after Slack is configured (#68289) 2026-01-20 22:04:41 +00:00
dpsutton
2287567b12 Fix Horror Show fingerprint OOMs (#68391)
* Fix Horror Show fingerprint OOMs

This may likely fix many OOMs that others are seeing. Anyone with large
schema will likely benefit from this.

```clojure
(let [database (toucan2.core/select-one :model/Database :id 2)]
  (metabase.sync.analyze.fingerprint/fingerprint-fields-for-db!
    database (fn [s t] (println "synced " s "for " (:name t)))))
```

This is how we fingerprint fields. DB 2 here is "Horror Show", a
database with 200 tables, each of which has 900 fields in it. This
database has zero rows in it, it it just a large schema.

Syncing this recently has caused huge cpu spikes and then OOM errors
until the instance is killed.

Profiling locally with

```
MB_JETTY_PORT=3091 java "$(socket-repl 6091)" -Xmx2g -jar metabase.jar
```
and visual vm hooked up, I run

```clojure
(let [database (toucan2.core/select-one :model/Database :id 2)]
  (metabase.sync.analyze.fingerprint/fingerprint-fields-for-db!
    database (fn [s t] (println "synced " s "for " (:name t)))))
```

And observe this behavior:
The ram usage approaches the 2gb limit and then bounces there. The CPU
begins to spike erratically and spend 10% of its time in GC.

Taking a heap dump we can see that we have 2.8 million ResultSetMetaData
objects occuping 183 Mb.

The ResultSetMetaData are closed over and kept in the metadata of the
snake hating maps stored in the cached metadata provider.

This brings into stark relief one simple way to fix this: don't use a
caching metadata provider. This PR just raising the body of the
fingerprint-tables-for-db function to not include
`(qp.store/with-metadata-provider (u/the-id database)` . There is no
benefit to caching here as this is an enumeration of the database. It is
by design not meant to reuse anything. If we didn't OOM we would end up
with 200 tables and 200*900 = 180,000 fields in the cache, each of which
was accessed only once.

That is what this PR fixes.

I also followed one other thread: why are we holding so many
ResultSetMetaData objects in memory. This seems to come about from a
change in https://github.com/metabase/metabase/pull/62330 specifically
this line:
https://github.com/metabase/metabase/blob/master/src/metabase/lib_be/metadata/jvm.clj#L63

```clojure
(mu/defn instance->metadata
  "Convert a (presumably) Toucan 2 instance of an application database model with `snake_case` keys to a MLv2 style
  metadata instance with `:lib/type` and `kebab-case` keys."
  [instance      :- :map
   metadata-type :- :keyword]
  (let [normalize (if-let [schema (get metadata-type->schema metadata-type)]
                    (fn [instance]
                      (lib.normalize/normalize schema instance))
                    identity)]
    (-> instance
        (perf/update-keys memoized-kebab-key)
        (assoc :lib/type metadata-type)
        normalize
        u.snake-hating-map/snake-hating-map
        ;; the line below here captures the result coming back from
        ;; toucan and has a closure over
        ;; toucan2.jdbc.read/make-column-thunk which has a closure over
        ;; (.getMetaData rset)
        (vary-meta assoc :metabase/toucan-instance instance))))
```

As it stands without any changes, the instance gets quite unstable and
falls over. If you comment out the `var-meta` form above, the graph
returns to a very normal sawtooth graph of memory usage and
reclaimation. But central to this is that it does not need to retain any
of this memory at all. But without this metadata containing the instance
and db classes, we only cache the table and field data and the
fingerprinting can finish. When we cache this stuff and also
inadvertently keep these db classes, we quite quickly run out of
headroom and the instance becomes unstable and falls over.

* fix kondo lint (removes a legacy usage as well :) 💰
2026-01-20 14:58:56 -06:00
Sloan Sparger
d9b8b9a2c0 [Metabot] Bug fix - animation color for nlq loading state (#68149)
fix animation color for nlq loading state
2026-01-20 14:57:32 -06:00
dpsutton
17f473a9f1 bit more cleanup (#68300)
* bit more cleanup

kept having a few flakes, couldn't consistently get it to pass 45 times
in a row locally. So just simplify it. Don't worry about "in process"
stats. Trying to get the webserver paused in the middle of a request was
occasionally deadlocking, often flaking.

So just calculate the simple ones and it's fine

```clojure
statistics-handler-test=> (clojure.test/run-test server-stats-test)

Testing metabase.server.statistics-handler-test

Ran 1 tests containing 13 assertions.
0 failures, 0 errors.
{:test 1, :pass 13, :fail 0, :error 0, :type :summary}
statistics-handler-test=> (dotimes [_ 100](server-stats-test))
nil
```

Also got rid of the macro that really only existed to collect the forms
into an anonymous function. Everything in-lined and now don't need a
kondo hook, etc

* remove unused alias

* kondo the refers

* reintroduce in-flight metrics

these:
```
(is (prometheus-test/approx= 1 (value! :jetty/requests-active)))
(is (prometheus-test/approx= 1 (value! :jetty/dispatched-active)))
```

Require a mechanism that can pause a request while active, verify that
the requests-active and dispatched active are incremented, and then
carry on. This is all kinda trivial except getting it wrong can
deadlock. Here are 500 successful runs of this test:

```
statistics-handler-test=> (dotimes [_ 100] (server-stats-test))
nil
statistics-handler-test=> (dotimes [_ 100] (server-stats-test))
nil
statistics-handler-test=> (dotimes [_ 100] (server-stats-test))
nil
statistics-handler-test=> (dotimes [_ 100] (server-stats-test))
nil
statistics-handler-test=> (dotimes [_ 100] (server-stats-test))
nil
```
2026-01-20 14:53:30 -06:00
Noah Moss
110cb5bca7 Streaming API response schema for documentation (#68386)
* Streaming API response schema POC

* Narrow a couple schemas

* fix comment

* bump ci

* oops, fix schema placement

* address feedback
2026-01-20 19:30:28 +00:00
Timofey Kachalov
1149c3e30c feat(embed-js): Add a counter-like query parameter for EmbedJS iframe src to allow multiple iframes loading in parallel (#68420) 2026-01-20 22:49:53 +04:00
Dan Stone
735b98d6f8 Filter incremental transform column listing to supported types only (#67686)
Filter extract-columns endpoint to only return numeric and temporal columns

The extract-columns-from-query function is used to populate the incremental
filter dropdown for native SQL transforms. Previously it returned all columns
without filtering on type, leading to undefined behaviour when unsupported
column types were selected.

This change filters the results to only include columns with types supported
for incremental checkpointing:
- Temporal types (timestamp, timestamp with timezone)
- Numeric types (integer, float, decimal)

Text, boolean, and other unsupported types are now filtered out, matching
the behaviour of MBQL and Python transforms which already filter columns
client-side.

Updated documentation to clarify this endpoint is specifically for incremental
transform checkpoint column selection.

Also validates that transform POST/PUT conform to this rule and you are unable to update the transform to have
a filter column of an unsupported type.
2026-01-20 18:00:36 +00:00
Jakub Chodorowicz
359158e594 Gdgt 1312 adjust range selection to work also with schemas and dbs (#68280) 2026-01-20 18:17:50 +01:00
Edward Paget
9a5e8227d0 fix: failing transforms revision test (#68422) 2026-01-20 16:40:22 +00:00
github-actions[bot]
287665a6fa Fix: Actions attached to models inside of Library(synced collection) break remote sync (#68252)
Fixes #68215

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude Code[bot] <noreply@anthropic.com>
Co-authored-by: Ed Paget <ed@metabse.coma>
Co-authored-by: Edward Paget <edpaget@users.noreply.github.com>
2026-01-20 10:23:56 -06:00
Jeff Bruemmer
82f16a8528 docs - guest embed locked parameters (#68293) 2026-01-20 10:50:26 -05:00
github-automation-metabase
45b4b682cc Auto-update documentation for master (#68402)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-01-20 09:58:17 -05:00
Denis Berezin
7a96e1f142 UXW-2497 fix remote-sync flaky test (#68368) 2026-01-20 17:16:05 +03:00
Gabriel Moreno
a48f246c50 Add transform owners and show them in Data Studio (#67840)
* feat: add owner fields

* feat: hydrate owner property on transforms

* feat: extend `start-run` function to accept `user_id` and use it on scheduled transform runs

* feat: implement 'Ownership' section in transform settings page

* test: implement unit specs

* test: add e2e spec

* feat: show owner avatar in transforms list

* Minor clojure improvements

* test: simplify & extend e2e spec

* refactor: simplify avatar usage & change unassigned copy to "No owner"

* refactor: de-duplicate success & error messages

* test: add missing serialization test transform attributes

* test: fix e2e spec by scrolling into view first

* refactor: attribute manual runs to current user rather than owner

* refactor: cleanup

* tests: add missing user id parameter for jobs unit spec

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>
2026-01-20 10:07:33 -04:00
Sébastien
dd00b33545 Fix tracking event never set to default in embed (#68407)
* fix tracking event never set to default in embed

* added e2e
2026-01-20 14:35:08 +01:00
Jakub Chodorowicz
51eab9a902 add openapi check to ci pipeline (#67854)
openapi check with self healing, all hidden behind ci label
2026-01-20 14:31:43 +01:00
Dan Stone
4b47763109 Restore future in mock client calls so that interrupts are isolated from caller (#68408)
Ensure client-full-response uses future so that interrupts are isolated from caller

An intentional server side .interrupt might leak and cause subsequent
code to fail.

Prior to https://github.com/metabase/metabase/pull/65872 the
client-full-response used a with-timeout, which incidentally put the
http handler in a future (isolating the interrupt).

This commit restores the previous behaviour, in a more intentional way.
2026-01-20 12:59:56 +00:00
github-automation-metabase
d4960babe5 [chore] Update llms.txt for master (#68410)
Update llms.txt and llms-full.txt (master)

Regenerated llms.txt to include all markdown files from the docs/ directory.
Also generated llms-full.txt files for the embedding section.

🤖 Generated automatically by llms-txt-generate workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
2026-01-20 12:57:37 +00:00
Gabriel Moreno
3856fd989e Data Studio UX improvements & QoL fixes (#68320)
* fix(uxw-2711): dependency graph corner radius

* fix(uxw-2706): give snippets a default name

* feat(uxw-2750): use actual link elements for transform list page

* feat(uxw-2750): use actual links for library section layout items

* fix(uxw-2751): prevent transform cancel handler from wiping changes
2026-01-20 08:37:54 -04:00
Nicola Mometto
2ac5ed4fb0 add task-runs to usage analytics (#68288)
* feat: ad v_task_runs

* feat: update v_tasks view

* update permissions/test

* add views

* remove fieldusersettings from export, channels

* add task runs model
2026-01-20 10:39:56 +00:00
Sébastien
1b80d7dc16 Enable editing/creating native questions in the SDK (#68138)
* .

* .

* .

* edit is actually working

* add now works too

* fix

* added e2e test

* doc

* .

* added a wait to see if that helps

* better waits

* .

* addressed comments

* Apply suggestions from code review

Co-authored-by: Alex Yarosh <alexandra@metabase.com>

* test

* test

---------

Co-authored-by: Alex Yarosh <alexandra@metabase.com>
2026-01-20 08:37:11 +01:00
github-automation-metabase
34266fc636 Update translations 2026-01-20 (#68403)
* Update translation sources 2026-01-20

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-20 06:23:53 +00:00
Alexander Polyankin
e3ced91a43 Make filtering in the deps graph consistent with the task lists (#68373) 2026-01-20 03:36:05 +00:00
Anderson
a37b3fb0ec Implement read-only mode on Snippet detail page (#68268)
* Implement read-only on Snippet detail page

* Add more tests

* Fix mock

* translate string
2026-01-20 00:48:51 +00:00
Romeo Van Snick
1284c201b5 MBQL lib test helpers (#68168)
* Add test helper definition and implement joins

* Add support for fields clause

* Be explicit with types

* Pull out findJoinStrategy helper

* Pull out appenTestQueryStage

* Simplify helpers

* Move helper

* Allow disamiguating column name

* Fix rhs columns

* pull out createTestJoinClause

* Add filters

* Add aggregations

* Add breakouts

* Add expressions

* Add ordering

* Add limiting rows

* Make source optional

* Make provider the first argument

* Expand example in tests

* Automatically pick the base column if no table was specified

* Remove unused sourceMetadata argument where not needed

* Fix join metadata

* Improve types

* Add createNativeQuery and createNativeJsQuery helpers

* Use column groups to disambiguate instead of table name

* Test the results

* Deprecate createQuery

* Replace createQueryWithClauses with createTestQuery

* Add SAMPLE_PROVIDER

* Add helpers to build metadata provider

* Use displayName for groupName

* Remove raw mbql in test and replace it with Lib inspections
2026-01-20 11:04:16 +11:00
Nemanja Glumac
feb0c5b34a Add pre-commit hook to auto-fix unused requires flagged by clj-kondo (#68274)
This introduces a pre-commit hook that automatically removes unused require
statements detected by clj-kondo, preventing CI failures.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 00:10:01 +01:00
825 changed files with 218494 additions and 138976 deletions

View File

@@ -0,0 +1,12 @@
Generate a CI failure report for PR $PR_NUM_OR_URL (or current branch if no argument given).
Run `./bin/mage ci-report $PR_NUM_OR_URL` to generate the report, then:
1. **Summarize the failures** - What tests failed and in which jobs?
2. **Categorize** - Are these real failures, flaky tests, or infrastructure issues?
3. **Recommend next steps**:
- If flaky: suggest rerunning with `gh run rerun <run-id> --failed -R metabase/metabase`
- If real: identify the root cause and suggest fixes
- If infrastructure: note the issue type (timeout, OOM, network)
Focus on actionable insights. If all checks are passing, confirm that briefly.

View File

@@ -29,7 +29,7 @@ This guide covers Clojure and ClojureScript coding conventions for Metabase. See
**Comments:**
- `TODO` format: `;; TODO (Name M/D/YY) -- description`
- `TODO` format: `;; TODO (Name YYYY-MM-DD) -- description`
## Code Organization

View File

@@ -0,0 +1,504 @@
---
name: analytics-events
description: Add product analytics events to track user interactions in the Metabase frontend
allowed-tools: Read, Write, Edit, Grep, Glob
---
# Frontend Analytics Events Skill
This skill helps you add product analytics (Snowplow) events to track user interactions in the Metabase frontend codebase.
## Quick Reference
Analytics events in Metabase use Snowplow with typed event schemas. All events must be defined in TypeScript types before use.
**Key Files:**
- `frontend/src/metabase-types/analytics/event.ts` - Event type definitions
- `frontend/src/metabase-types/analytics/schema.ts` - Schema registry
- `frontend/src/metabase/lib/analytics.ts` - Core tracking functions
- Feature-specific `analytics.ts` files - Tracking function wrappers
## Quick Checklist
When adding a new analytics event:
- [ ] Define event type in `frontend/src/metabase-types/analytics/event.ts`
- [ ] Add event to appropriate union type (e.g., `DataStudioEvent`, `SimpleEvent`)
- [ ] Create tracking function in feature's `analytics.ts` file
- [ ] Import and call tracking function at the interaction point
- [ ] Use `trackSimpleEvent()` for basic events (most common)
## Event Schema Types
### 1. Simple Events (Most Common)
Use `SimpleEventSchema` for straightforward tracking. It supports these standard fields:
```typescript
type SimpleEventSchema = {
event: string; // Required: Event name (snake_case)
target_id?: number | null; // Optional: ID of affected entity
triggered_from?: string | null; // Optional: UI location/context
duration_ms?: number | null; // Optional: Duration in milliseconds
result?: string | null; // Optional: Outcome (e.g., "success", "failure")
event_detail?: string | null; // Optional: Additional detail/variant
};
```
**When to use:** 90% of events fit this schema. Use for clicks, opens, closes, creates, deletes, etc.
### 2. Custom Schemas (legacy, no events are being added)
Consider adding new event schema only in very special cases.
**Examples:** `DashboardEventSchema`, `CleanupEventSchema`, `QuestionEventSchema`
## Step-by-Step: Adding a Simple Event
### Example: Track when a user applies filters in a table picker
#### Step 1: Define Event Types
Add event type definitions to `frontend/src/metabase-types/analytics/event.ts`:
```typescript
export type DataStudioTablePickerFiltersAppliedEvent = ValidateEvent<{
event: "data_studio_table_picker_filters_applied";
}>;
export type DataStudioTablePickerFiltersClearedEvent = ValidateEvent<{
event: "data_studio_table_picker_filters_cleared";
}>;
```
#### Step 2: Add to Union Type
Find or create the appropriate union type and add your events:
```typescript
export type DataStudioEvent =
| DataStudioLibraryCreatedEvent
| DataStudioTablePublishedEvent
| DataStudioGlossaryCreatedEvent
| DataStudioGlossaryEditedEvent
| DataStudioGlossaryDeletedEvent
| DataStudioTablePickerFiltersAppliedEvent // <- Add here
| DataStudioTablePickerFiltersClearedEvent; // <- Add here
```
#### Step 3: Create Tracking Functions
In your feature's `analytics.ts` file (e.g., `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`):
```typescript
import { trackSimpleEvent } from "metabase/lib/analytics";
export const trackDataStudioTablePickerFiltersApplied = () => {
trackSimpleEvent({
event: "data_studio_table_picker_filters_applied",
});
};
export const trackDataStudioTablePickerFiltersCleared = () => {
trackSimpleEvent({
event: "data_studio_table_picker_filters_cleared",
});
};
```
#### Step 4: Use in Components
Import and call the tracking function at the interaction point:
```typescript
import {
trackDataStudioTablePickerFiltersApplied,
trackDataStudioTablePickerFiltersCleared,
} from "metabase-enterprise/data-studio/analytics";
function FilterPopover({ filters, onSubmit }) {
const handleReset = () => {
trackDataStudioTablePickerFiltersCleared(); // <- Track here
onSubmit(emptyFilters);
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
trackDataStudioTablePickerFiltersApplied(); // <- Track here
onSubmit(form);
}}
>
{/* form content */}
</form>
);
}
```
## Using SimpleEventSchema Fields
### Example: Event with target_id
```typescript
// Type definition
export type DataStudioLibraryCreatedEvent = ValidateEvent<{
event: "data_studio_library_created";
target_id: number | null;
}>;
// Tracking function
export const trackDataStudioLibraryCreated = (id: CollectionId) => {
trackSimpleEvent({
event: "data_studio_library_created",
target_id: Number(id),
});
};
// Usage
trackDataStudioLibraryCreated(newLibrary.id);
```
### Example: Event with triggered_from
```typescript
// Type definition
export type NewButtonClickedEvent = ValidateEvent<{
event: "new_button_clicked";
triggered_from: "app-bar" | "empty-collection";
}>;
// Tracking function
export const trackNewButtonClicked = (location: "app-bar" | "empty-collection") => {
trackSimpleEvent({
event: "new_button_clicked",
triggered_from: location,
});
};
// Usage
<Button onClick={() => {
trackNewButtonClicked("app-bar");
handleCreate();
}}>
New
</Button>
```
### Example: Event with event_detail
```typescript
// Type definition
export type MetadataEditEvent = ValidateEvent<{
event: "metadata_edited";
event_detail: "type_casting" | "semantic_type_change" | "visibility_change";
triggered_from: "admin" | "data_studio";
}>;
// Tracking function
export const trackMetadataChange = (
detail: "type_casting" | "semantic_type_change" | "visibility_change",
location: "admin" | "data_studio"
) => {
trackSimpleEvent({
event: "metadata_edited",
event_detail: detail,
triggered_from: location,
});
};
// Usage
trackMetadataChange("semantic_type_change", "data_studio");
```
### Example: Event with result and duration
```typescript
// Type definition
export type MoveToTrashEvent = ValidateEvent<{
event: "moved-to-trash";
target_id: number | null;
triggered_from: "collection" | "detail_page" | "cleanup_modal";
duration_ms: number | null;
result: "success" | "failure";
event_detail: "question" | "model" | "metric" | "dashboard";
}>;
// Tracking function
export const trackMoveToTrash = (params: {
targetId: number | null;
triggeredFrom: "collection" | "detail_page" | "cleanup_modal";
durationMs: number | null;
result: "success" | "failure";
itemType: "question" | "model" | "metric" | "dashboard";
}) => {
trackSimpleEvent({
event: "moved-to-trash",
target_id: params.targetId,
triggered_from: params.triggeredFrom,
duration_ms: params.durationMs,
result: params.result,
event_detail: params.itemType,
});
};
// Usage with timing
const startTime = Date.now();
try {
await moveToTrash(item);
trackMoveToTrash({
targetId: item.id,
triggeredFrom: "collection",
durationMs: Date.now() - startTime,
result: "success",
itemType: "question",
});
} catch (error) {
trackMoveToTrash({
targetId: item.id,
triggeredFrom: "collection",
durationMs: Date.now() - startTime,
result: "failure",
itemType: "question",
});
}
```
## Naming Conventions
### Event Names (snake_case)
```typescript
// Good
"data_studio_library_created"
"table_picker_filters_applied"
"metabot_chat_opened"
// Bad
"DataStudioLibraryCreated" // Wrong case
"tablePickerFiltersApplied" // Wrong case
"filters-applied" // Use underscore, not hyphen
```
### Event Type Names (PascalCase with "Event" suffix)
```typescript
// Good
DataStudioLibraryCreatedEvent
TablePickerFiltersAppliedEvent
MetabotChatOpenedEvent
// Bad
dataStudioLibraryCreated // Wrong case
DataStudioLibraryCreated // Missing "Event" suffix
```
### Tracking Function Names (camelCase with "track" prefix)
```typescript
// Good
trackDataStudioLibraryCreated
trackTablePickerFiltersApplied
trackMetabotChatOpened
// Bad
DataStudioLibraryCreated // Missing "track" prefix
track_library_created // Wrong case
logLibraryCreated // Use "track" prefix
```
## Common Patterns
### Pattern 1: Feature-Specific Union Types
Group related events together:
```typescript
export type DataStudioEvent =
| DataStudioLibraryCreatedEvent
| DataStudioTablePublishedEvent
| DataStudioGlossaryCreatedEvent;
export type MetabotEvent =
| MetabotChatOpenedEvent
| MetabotRequestSentEvent
| MetabotFixQueryClickedEvent;
// Then add to SimpleEvent union
export type SimpleEvent =
| /* other events */
| DataStudioEvent
| MetabotEvent
| /* more events */;
```
### Pattern 2: Conditional Tracking
Track different events based on user action:
```typescript
const handleSave = async () => {
if (isNewItem) {
await createItem(data);
trackItemCreated(newItem.id);
} else {
await updateItem(id, data);
trackItemUpdated(id);
}
};
```
## Common Pitfalls
### Don't: Add custom fields to SimpleEvent
```typescript
// WRONG - SimpleEvent doesn't support custom fields
export const trackFiltersApplied = (filters: FilterState) => {
trackSimpleEvent({
event: "filters_applied",
data_layer: filters.dataLayer, // ❌ Not in SimpleEventSchema
data_source: filters.dataSource, // ❌ Not in SimpleEventSchema
with_owner: filters.hasOwner, // ❌ Not in SimpleEventSchema
});
};
// RIGHT - Use only standard SimpleEventSchema fields
export const trackFiltersApplied = () => {
trackSimpleEvent({
event: "filters_applied",
});
};
// Or use event_detail for a single variant
export const trackFilterApplied = (filterType: string) => {
trackSimpleEvent({
event: "filter_applied",
event_detail: filterType, // ✓ "data_layer", "data_source", etc.
});
};
```
### Don't: Forget to add event to union type
```typescript
// Define the event
export type NewFeatureClickedEvent = ValidateEvent<{
event: "new_feature_clicked";
}>;
// ❌ WRONG - Forgot to add to SimpleEvent union
// Event won't be recognized by TypeScript
// ✓ RIGHT - Add to appropriate union
export type SimpleEvent =
| /* other events */
| NewFeatureClickedEvent;
```
### Don't: Mix up event name formats
```typescript
// WRONG
event: "dataStudioLibraryCreated" // camelCase
event: "data-studio-library-created" // kebab-case
event: "Data_Studio_Library_Created" // Mixed case
// RIGHT
event: "data_studio_library_created" // snake_case
```
### Don't: Track PII or sensitive data
```typescript
// WRONG - Don't track user emails, names, or sensitive data
trackSimpleEvent({
event: "user_logged_in",
event_detail: user.email, // ❌ PII
});
// RIGHT - Track non-sensitive identifiers only
trackSimpleEvent({
event: "user_logged_in",
target_id: user.id, // ✓ Just the ID
});
```
### Don't: Forget to track both success and failure
```typescript
// WRONG - Only tracking success
try {
await saveData();
trackDataSaved();
} catch (error) {
// ❌ No tracking for failure case
}
// RIGHT - Track both outcomes
try {
await saveData();
trackDataSaved({ result: "success" });
} catch (error) {
trackDataSaved({ result: "failure" });
}
```
## Testing Analytics Events
While developing, you can verify events are firing:
1. **Check browser console** - When `SNOWPLOW_ENABLED=true` in dev, events are logged
2. **Use shouldLogAnalytics** - Set in `metabase/env` to see all analytics in console
3. **Check Snowplow debugger** - Browser extension for Snowplow events
Example console output:
```
[SNOWPLOW EVENT | event sent:true], data_studio_table_picker_filters_applied
```
## File Organization
### Where to put tracking functions:
```
Feature-specific analytics functions:
frontend/src/metabase/{feature}/analytics.ts
enterprise/frontend/src/metabase-enterprise/{feature}/analytics.ts
Event type definitions (all in one place):
frontend/src/metabase-types/analytics/event.ts
Core tracking utilities:
frontend/src/metabase/lib/analytics.ts
```
## Real-World Examples
See these files for reference:
- **Simple events**: `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`
- **Events with variants**: `frontend/src/metabase/dashboard/analytics.ts`
- **Complex events**: `frontend/src/metabase/query_builder/analytics.js`
- **Event type examples**: `frontend/src/metabase-types/analytics/event.ts`
## Workflow Summary
1. **Identify the user interaction** to track
2. **Decide on event name** (snake_case, descriptive)
3. **Define event type** in `event.ts` using `ValidateEvent`
4. **Add to union type** (create feature union if needed)
5. **Create tracking function** in feature's `analytics.ts`
6. **Import and call** at the interaction point
7. **Test** that events fire correctly
## Tips
- **Be specific** - `filters_applied` is better than `action_performed`
- **Use past tense** - `library_created` not `create_library`
- **Group related events** - Create feature-specific event union types
- **Track meaningful actions** - Not every click needs tracking
- **Consider the data** - What would you want to analyze later?
- **Stay consistent** - Follow existing naming patterns in the codebase
- **Document context** - Use `triggered_from` to track where the action happened

View File

@@ -54,7 +54,7 @@ Use this to scan through changes efficiently:
- [ ] Public vars in `src` or `enterprise/backend/src` have useful docstrings
- [ ] Docstrings use Markdown conventions
- [ ] References use `[[other-var]]` not backticks
- [ ] `TODO` comments include author and date: `;; TODO (Name 1/1/25) -- description`
- [ ] `TODO` comments include author and date: `;; TODO (Name 2025-01-01) -- description`
### Code Organization
@@ -118,7 +118,7 @@ Quick scan for common issues:
| `update-db`, `save-model` | Missing `!` for side effects: `update-db!`, `save-model!` |
| `snake_case_var` | Should use kebab-case |
| Public var without docstring | Add docstring explaining purpose |
| `;; TODO fix this` | Missing author/date: `;; TODO (Name 1/1/25) -- description` |
| `;; TODO fix this` | Missing author/date: `;; TODO (Name 2025-01-01) -- description` |
| `(defn foo ...)` in namespace used elsewhere | Should be `(defn ^:private foo ...)` |
| Function > 20 lines | Consider breaking up into smaller functions |
| `/api/dashboards/:id` | Use singular: `/api/dashboard/:id` |

View File

@@ -65,6 +65,7 @@
:metabase/missing-test-expr-requires-in-cljs {:level :warning}
:metabase/modules {:level :warning} ; more config in `.clj-kondo/config/modules/config.edn`
:metabase/namespace-name {:level :warning}
:metabase/no-jsqlparser-imports {:level :warning}
:metabase/require-shape-checker {:level :warning}
:metabase/test-helpers-use-non-thread-safe-functions {:level :warning} ; more config in `.clj-kondo/config/test-helpers/config.edn`
:metabase/tests-must-live-in-known-modules {:level :warning}
@@ -299,7 +300,8 @@
metabase-enterprise.transforms.interface/execute! {:message "Use metabase-enterprise.transforms.execute/execute! instead of metabase-enterprise.transforms.interface/execute!"}
next.jdbc/active-tx? {:message "Use metabase.app-db.core/in-transaction? to see if we are in a transaction"}
toucan2.core/debug {:message "Don't commit code using t2/debug"}
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}}
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}
macaw.core/parsed-query {:message "Use metabase.driver.util/parsed-query instead of macaw.core/parsed-query"}}
:discouraged-namespace
{camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
@@ -522,8 +524,10 @@
metabase-enterprise.advanced-permissions.models.permissions.application-permissions-test/with-new-group-and-current-graph hooks.common/with-two-top-level-bindings
metabase-enterprise.audit-app.pages-test/with-temp-objects hooks.common/with-one-binding
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
metabase-enterprise.transforms-python.drivers-test/with-test-table hooks.common/with-two-bindings
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding

View File

@@ -383,7 +383,6 @@
:uses #{api
app-db
collections
config
eid-translation
events
lib-be
@@ -614,7 +613,6 @@
metabase.embedding.validation}
:uses #{analytics
api
config
premium-features
server
settings
@@ -810,6 +808,7 @@
lib-be
models
permissions
remote-sync
search
util}}
@@ -1457,6 +1456,7 @@
models
permissions
premium-features
query-processor
request
task
util}}
@@ -1500,7 +1500,6 @@
:uses #{analytics
api
collections
config
events
models
util}}
@@ -1650,7 +1649,6 @@
:uses #{analytics
api
app-db
config
database-routing
driver
events
@@ -2333,6 +2331,7 @@
premium-features
queries
query-processor
remote-sync
request
revisions
search

View File

@@ -1,6 +0,0 @@
(ns macros.metabase.server.statistics-handler-test)
(defmacro with-server
[[_hold-chan _hit-chan] & body]
`(let []
~@body))

View File

@@ -99,12 +99,48 @@
:message "Metabase namespaces should have the form metabase[-enterprise].<module>.* [:metabase/namespace-name]"
:type :metabase/namespace-name)))))))
(defn- ns-form-node->import-node [ns-form-node]
(some (fn [node]
(when (and (hooks/list-node? node)
(let [first-child (first (:children node))]
(and (hooks/keyword-node? first-child)
(= (hooks/sexpr first-child) :import))))
node))
(:children ns-form-node)))
(defn- jsqlparser-import?
"Check if a symbol or string represents a jsqlparser import."
[s]
(str/starts-with? (str s) "net.sf.jsqlparser"))
(defn- lint-jsqlparser-imports [ns-form-node]
(when-let [import-node (ns-form-node->import-node ns-form-node)]
(doseq [node (rest (:children import-node))
:when (not (contains? (hooks.common/ignored-linters node) :metabase/no-jsqlparser-imports))]
(cond
;; Handle (net.sf.jsqlparser.foo Bar Baz) form
(hooks/list-node? node)
(let [package-node (first (:children node))
package-name (when package-node (str (hooks/sexpr package-node)))]
(when (jsqlparser-import? package-name)
(hooks/reg-finding! (assoc (meta node)
:message "Don't import net.sf.jsqlparser classes directly. This code should be agnostic to the parsing library. [:metabase/no-jsqlparser-imports]"
:type :metabase/no-jsqlparser-imports))))
;; Handle net.sf.jsqlparser.foo.Bar form (single class import)
(hooks/token-node? node)
(when (jsqlparser-import? (hooks/sexpr node))
(hooks/reg-finding! (assoc (meta node)
:message "Don't import net.sf.jsqlparser classes directly. This code should be agnostic to the parsing library. [:metabase/no-jsqlparser-imports]"
:type :metabase/no-jsqlparser-imports)))))))
(defn lint-ns [x]
(doto (:node x)
lint-require-shapes
lint-requires-on-new-lines
(lint-modules (modules/config x))
lint-namespace-name)
lint-namespace-name
lint-jsqlparser-imports)
x)
(comment

View File

@@ -1,4 +1,5 @@
name: Prepare front-end environment
description: "Prepare the front-end environment for the CI build"
inputs:
node-version:
description: "The version of Node.js to use, overriding .nvmrc"

40
.github/scripts/find-linear-issue.js vendored Normal file
View File

@@ -0,0 +1,40 @@
async function findLinearIssue({ githubIssueUrl, linearApiKey }) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: linearApiKey,
},
body: JSON.stringify({
query: `
query {
attachmentsForURL(url: "${githubIssueUrl}") {
nodes {
issue { identifier }
}
}
}
`,
}),
});
const data = await response.json();
if (!response.ok) {
return { success: false, message: `Linear API error: ${response.status}` };
}
if (!data.data?.attachmentsForURL?.nodes?.length) {
return {
success: false,
message: `No Linear issue linked to ${githubIssueUrl}`,
};
}
return {
success: true,
issue: data.data.attachmentsForURL.nodes[0].issue.identifier,
};
}
module.exports = { findLinearIssue };

View File

@@ -19,11 +19,12 @@ while [[ $# -gt 0 ]]; do
done
echo "Cloning python-runner repository..."
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
git clone https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
if [ "$SKIP_START" = false ]; then
echo "Starting python-runner..."
cd python-runner
git checkout origin/memory_improvements_2
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 ..

View File

@@ -70,6 +70,31 @@ jobs:
core.setFailed(error.message);
}
- name: Find linked Linear issue
id: find_linear
continue-on-error: true
uses: actions/github-script@v7
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
ISSUE_NUMBER: ${{ steps.get_issue.outputs.issue_number }}
with:
script: |
const { findLinearIssue } = require('${{ github.workspace }}/.github/scripts/find-linear-issue.js');
const githubIssueUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${process.env.ISSUE_NUMBER}`;
const result = await findLinearIssue({
githubIssueUrl,
linearApiKey: process.env.LINEAR_API_KEY
});
if (result.success) {
console.log(`Found Linear issue: ${result.issue}`);
core.setOutput('linear_issue', result.issue);
} else {
console.log(`Could not find Linear issue: ${result.message}`);
}
- name: Set up git configuration
run: |
git config --global user.name "Claude Code[bot]"
@@ -259,6 +284,7 @@ jobs:
core.setOutput('reviewers', issueCreator);
- name: Create Pull Request
id: create_pr
if: steps.check_changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@v6
with:
@@ -279,6 +305,8 @@ jobs:
body: |
This PR automatically fixes the bug reported in #${{ env.ISSUE_NUMBER }}.
${{ steps.find_linear.outputs.linear_issue && format('Closes {0}', steps.find_linear.outputs.linear_issue) || '' }}
## Bug Report
**Original Issue:** #${{ env.ISSUE_NUMBER }}
**Reporter:** @${{ env.ISSUE_AUTHOR }}
@@ -299,7 +327,6 @@ jobs:
- If issues are found, either fix them or close this PR
---
Closes #${{ env.ISSUE_NUMBER }}
🤖 Generated with [Claude Code](https://claude.ai/code)
@@ -309,6 +336,7 @@ jobs:
automated-fix
ci skip
reviewers: ${{ steps.prepare_reviewers.outputs.reviewers }}
assignees: ${{ github.actor }}
- name: Log existing branch update
if: steps.check_changes.outputs.has_changes == 'true' && env.BRANCH_EXISTS == 'true'

View File

@@ -1,33 +0,0 @@
name: Cleanup caches by a branch
on:
pull_request:
types:
- closed
jobs:
cleanup:
runs-on: ubuntu-22.04
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
REPO=${{ github.repository }}
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,23 +1,110 @@
name: Codespell
on:
push:
branches:
- "master"
- "release-**"
pull_request:
schedule:
# Run nightly at 4:00 AM UTC
- cron: "0 4 * * *"
workflow_dispatch:
jobs:
codespell:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: codespell-project/actions-codespell@v2
- name: Install codespell
run: pip install codespell
- name: Set up git configuration
run: |
git config --global user.name "Claude Code[bot]"
git config --global user.email "noreply@anthropic.com"
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Run codespell check
id: codespell_check
continue-on-error: true
run: |
set -o pipefail
./bin/mage codespell 2>&1 | tee codespell_output.txt
- name: Generate GitHub App Token for Claude
if: steps.codespell_check.outcome == 'failure'
uses: actions/create-github-app-token@v1
id: claude-app-token
with:
# keep this list in sync with the list in `mage/src/mage/codespell.clj`
path: >-
docs
enterprise
frontend
modules/drivers
src
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
- name: Fix spelling errors with Claude Code
if: steps.codespell_check.outcome == 'failure'
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ steps.claude-app-token.outputs.token }}
prompt: |
You are tasked with fixing spelling errors detected by codespell in this repository.
## Codespell Output
The following spelling errors were detected:
```
$(cat codespell_output.txt)
```
## Instructions
1. **Review the codespell output and identify reported spelling errors** - The usual format codespell uses is `typo ==> correction`
2. **Fix every spelling error** - Use the Edit tool to fix each misspelling with the suggested correction
- If codespell suggests multiple corrections, choose the most appropriate one based on context
Human reviewers will verify the changes before merging, so fix all errors reported by codespell.
claude_args: |
--allowedTools "Write,Read,Glob,Grep,Edit,MultiEdit,Bash(cat*),Bash(ls*)"
--disallowedTools "Bash(git commit*),Bash(git push*),Bash(gh*)"
- name: Clean up temporary files
if: steps.codespell_check.outcome == 'failure'
run: rm -f codespell_output.txt
- name: Create Pull Request
if: steps.codespell_check.outcome == 'failure'
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: claude-fix/codespell-${{ steps.date.outputs.date }}
base: master
delete-branch: true
commit-message: |
Fix spelling errors detected by codespell (${{ steps.date.outputs.date }})
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
committer: "Claude Code[bot] <noreply@anthropic.com>"
author: "Claude Code[bot] <noreply@anthropic.com>"
title: "Fix spelling errors detected by codespell (${{ steps.date.outputs.date }})"
body: |
This PR automatically fixes spelling errors detected by the scheduled codespell check.
## Automated Fix
🤖 This fix was generated automatically by Claude Code based on codespell output.
**Please review carefully:**
- [ ] The spelling fixes are correct
- [ ] No code functionality was changed
- [ ] Technical terms were preserved correctly
---
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
labels: |
automated-fix
backport

View File

@@ -85,7 +85,6 @@ jobs:
be-tests-athena-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.athena-should-run == 'true' || needs.determine-driver-skips.outputs.athena-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -135,7 +134,6 @@ jobs:
be-tests-bigquery-cloud-sdk-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.bigquery-should-run == 'true' || needs.determine-driver-skips.outputs.bigquery-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -146,6 +144,7 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
# DS 2025-09-26 temporarily disabled while we diagnose flakes
# - name: Transforms Python Tests
# test-args: >-
@@ -206,7 +205,6 @@ jobs:
be-tests-clickhouse-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.clickhouse-should-run == 'true' || needs.determine-driver-skips.outputs.clickhouse-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -288,7 +286,6 @@ jobs:
be-tests-druid-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.druid-should-run == 'true' || needs.determine-driver-skips.outputs.druid-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -336,7 +333,6 @@ jobs:
be-tests-druid-jdbc-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.druid-jdbc-should-run == 'true' || needs.determine-driver-skips.outputs.druid-jdbc-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -384,7 +380,6 @@ jobs:
be-tests-databricks-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -432,7 +427,6 @@ jobs:
be-tests-databricks-multi-catalog-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -481,7 +475,6 @@ jobs:
be-tests-h2:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.h2-should-run == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -511,7 +504,6 @@ jobs:
be-tests-mysql-mariadb:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.mysql-mariadb-should-run == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -647,7 +639,6 @@ jobs:
be-tests-mongo:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.mongo-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -727,7 +718,6 @@ jobs:
be-tests-mongo-ssl:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.mongo-ssl-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-ssl-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -803,7 +793,7 @@ jobs:
be-tests-mongo-sharded-cluster-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.mongo-sharded-cluster-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-sharded-cluster-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -848,7 +838,6 @@ jobs:
be-tests-oracle:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.oracle-should-run == 'true' || needs.determine-driver-skips.outputs.oracle-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -933,7 +922,6 @@ jobs:
be-tests-postgres:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.postgres-should-run == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -1042,7 +1030,6 @@ jobs:
be-tests-presto-jdbc-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.presto-jdbc-should-run == 'true' || needs.determine-driver-skips.outputs.presto-jdbc-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -1123,7 +1110,6 @@ jobs:
be-tests-redshift-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.redshift-should-run == 'true' || needs.determine-driver-skips.outputs.redshift-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -1213,7 +1199,6 @@ jobs:
be-tests-snowflake-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.snowflake-should-run == 'true' || needs.determine-driver-skips.outputs.snowflake-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -1224,6 +1209,7 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
# DS 2025-09-26 temporarily disabled while we diagnose flakes
# - name: Transforms Python Tests
@@ -1294,7 +1280,6 @@ jobs:
be-tests-sparksql-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.sparksql-should-run == 'true' || needs.determine-driver-skips.outputs.sparksql-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -1340,7 +1325,6 @@ jobs:
be-tests-sqlite-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.sqlite-should-run == 'true' || needs.determine-driver-skips.outputs.sqlite-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -1381,7 +1365,6 @@ jobs:
be-tests-sqlserver:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.sqlserver-should-run == 'true' || needs.determine-driver-skips.outputs.sqlserver-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
@@ -1502,7 +1485,6 @@ jobs:
login-to-ecr:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.vertica-should-run == 'true' || needs.determine-driver-skips.outputs.vertica-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
@@ -1532,7 +1514,6 @@ jobs:
be-tests-vertica-ee:
needs: [determine-driver-skips, login-to-ecr]
if: ${{ needs.determine-driver-skips.outputs.vertica-should-run == 'true' || needs.determine-driver-skips.outputs.vertica-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:

View File

@@ -120,6 +120,10 @@ jobs:
- name: Prepare Cypress environment
id: cypress-prep
uses: ./.github/actions/prepare-cypress
- name: Compile CLJS
run: yarn build-pure:cljs
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start

View File

@@ -104,6 +104,9 @@ jobs:
if: github.run_attempt != '1'
run: echo "specs=$(cat failed-specs)" >> $GITHUB_ENV
- name: Compile CLJS
run: yarn build-pure:cljs
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start

44
.github/workflows/openapi-check.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: OpenAPI Check
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check-openapi-specs:
runs-on: ubuntu-22.04
timeout-minutes: 5
if: contains(github.event.pull_request.labels.*.name, 'openapi-self-healing')
steps:
- uses: actions/checkout@v4
with:
# we're intentionally using METABASE_AUTOMATION_USER_TOKEN to trigger the whole CI pipeline again
# after the self-healing commit is created
# if we used GITHUB_TOKEN, the self-healing commit would not trigger the CI pipeline again
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
# fetch full git history to allow fetching the latest base branch commit and comparing changes
fetch-depth: 0
- name: Setup Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Generate version.properties
run: ./bin/build.sh :steps [:version]
- name: Get latest base branch commit
run: |
git fetch origin ${{ github.event.pull_request.base.ref }}
echo "LATEST_BASE_SHA=$(git rev-parse origin/${{ github.event.pull_request.base.ref }})" >> $GITHUB_ENV
- name: Check OpenAPI specs are up to date
env:
AUTO_COMMIT: "true"
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
BASE_SHA: ${{ env.LATEST_BASE_SHA }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_SHA: ${{ github.sha }}
run: ./bin/openapi-check.sh

1
.gitignore vendored
View File

@@ -106,7 +106,6 @@ dev/serialization_deltas/
/trace-output/
/temp-types/
/frontend/src/metabase-types/openapi
/resources/openapi/openapi.json
**/cypress_sample_database.json
**/cypress_sample_instance_data.json

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useEffect } from "react";
import React, { useEffect, useMemo } from "react";
import { ThemeProvider } from "metabase/ui";
// @ts-expect-error: See metabase/lib/delay
@@ -15,10 +16,13 @@ import "@mantine/dates/styles.css";
import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider";
import { getMetabaseCssVariables } from "metabase/styled-components/theme/css-variables";
import { css, Global, useTheme } from "@emotion/react";
import { Global, css, useTheme } from "@emotion/react";
import { baseStyle, rootStyle } from "metabase/css/core/base.styled";
import { defaultFontFiles } from "metabase/css/core/fonts.styled";
import { saveDomImageStyles } from "metabase/visualizations/lib/image-exports";
import { initialize, mswLoader } from "msw-storybook-addon";
// Note: Changing the names of the stories may impact loki visual testing. Please ensure that
@@ -107,7 +111,9 @@ function CssVariables() {
}, []);
// This can get expensive so we should memoize it separately
const cssVariables = useMemo(() => getMetabaseCssVariables(theme), [theme]);
const cssVariables = useMemo(() => {
return getMetabaseCssVariables({ theme });
}, [theme]);
const styles = useMemo(() => {
return css`

View File

@@ -19,6 +19,10 @@ For detailed guidance on writing and reviewing code and documentation, see the s
- **[docs-write](.claude/skills/docs-write/SKILL.md)** - Documentation writing with Metabase style guide
- **[docs-review](.claude/skills/docs-review/SKILL.md)** - Documentation review checklist
## Frontend
- **[analytics-events](.claude/skills/analytics-events/SKILL.md)** - Add product analytics events to track user interactions
## Tool Preferences
If `clojure-mcp` tools are available, prefer them over shell-based alternatives for Clojure development.

69
bb.edn
View File

@@ -83,6 +83,15 @@
:requires [[mage.kondo :as kondo]]
:task (task! (kondo/kondo-updated (:arguments parsed)))}
fix-unused-requires
{:doc "Removes unused requires flagged by clj-kondo"
:examples [["./bin/mage fix-unused-requires src/metabase/lib/validate.cljc" "Fix unused requires in a specific file"]
["./bin/mage fix-unused-requires src" "Fix unused requires in all files in src"]]
:requires [[mage.fix-unused-requires :as fix]]
:arg-schema [:sequential [:string {:name "file-or-directory"
:description "Files or directories to fix unused requires in"}]]
:task (task! (fix/fix-files parsed))}
check
{:doc "Checks whether we can compile all source files (and finds circular dependencies)"
:examples [["./bin/mage check"]]
@@ -274,6 +283,20 @@
:requires [[mage.doctor :as doctor]]
:task (task! (doctor/doctor!))}
ci-report
{:doc "Generate a CI failure report for a PR"
:examples [["./bin/mage ci-report" "Check current branch, find its PR, and generate report"]
["./bin/mage ci-report 68292" "Generate report for PR #68292"]
["./bin/mage ci-report https://github.com/metabase/metabase/pull/68292" "Generate report from PR URL"]]
:usage-fn (fn [_]
(str "Fetches CI check status and failed job logs from GitHub Actions.\n"
"Parses Trunk test reports to extract test failure details.\n\n"
"With no arguments, looks up the PR for your current git branch.\n\n"
"Requires GitHub CLI (gh) to be installed and authenticated.\n"
"If you see 'gh: command not found' or auth errors, run: gh auth login"))
:requires [[mage.ci-report :as ci-report]]
:task (task! (ci-report/generate-report! parsed))}
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Private Tasks:
;; These tasks are more for internal tooling, e.g. for use from git hooks etc.
@@ -284,32 +307,38 @@
:examples [["./bin/mage -driver-decisions --git-ref=master --is-master-or-release=false --pr-labels= --skip=false" "Determine driver decisions for a PR"]
["./bin/mage -driver-decisions --pr-labels=ci:all-cloud-drivers" "Run all cloud drivers via label"]
["./bin/mage -driver-decisions --github-output-only" "Output only GITHUB_OUTPUT format (for CI)"]]
:requires [[clojure.string :as str]
[table.core :as table]
[mage.modules]]
:usage-fn
(fn [_]
(->>
["# How we decide to run a driver's tests. For each driver, we check these conditions in order (first match wins):"
" |----------|-------------------------------------|--------|-------------------------------------------|"
" | Priority | Condition | Result | Reason |"
" |----------|-------------------------------------|--------|-------------------------------------------|"
" | 1 | Global skip (no backend changes) | SKIP | workflow skip (no backend changes) |"
" | 2 | Driver is :h2 | RUN | H2 always runs |"
" | 3 | Driver is quarantined | SKIP | driver is quarantined |"
" | 3(a) | ...and has break-quarantine-X label | RUN | anti-quarantine label present |"
" | 4 | On master or release branch | RUN | master/release branch |"
" | 5 | Cloud driver + ci:all-cloud-drivers | RUN | ci:all-cloud-drivers label |"
" | 6 | Cloud driver + its files changed | RUN | driver files changed |"
" | 7 | Cloud driver, no relevant changes | SKIP | no relevant changes for cloud driver |"
" | 8 | Driver deps affected by changes | RUN | driver module affected by shared code |"
" | 9 | Self-hosted driver, not affected | SKIP | driver module not affected |"
" |----------|-------------------------------------|--------|-------------------------------------------|"
""] (str/join "\n")))
(fn [_current-task]
(let [driver-triggers (vec (sort mage.modules/default-modules-which-trigger-drivers))]
(str
"# How we decide to run a driver's tests. For each driver, we check these conditions in order (first match wins):\n"
(with-out-str
(table/table [["Priority" "Condition" "Result" "Reason"]
["1" "Global skip (no backend changes)" "SKIP" "workflow skip (no backend changes)"]
["2" "Driver is :h2 or :postgres" "RUN" "H2/Postgres always run"]
["3" "Driver is quarantined" "SKIP" "driver is quarantined"]
["3(a)" "...and has break-quarantine-X label" "RUN" "anti-quarantine label present"]
["4" "On master or release branch" "RUN" "master/release branch"]
["5" "Cloud driver + ci:all-cloud-drivers" "RUN" "ci:all-cloud-drivers label"]
["6"
"Cloud driver + its files changed"
"RUN"
(str "any of " (pr-str driver-triggers) " affected by cloud-driver module changes")]
["7" "Cloud driver, no relevant changes" "SKIP" "no relevant changes for cloud driver"]
["8"
(str (pr-str driver-triggers) " module deps affected")
"RUN"
(str "any of " (pr-str driver-triggers) " affected by module changes")]
["9" "Self-hosted driver, not affected" "SKIP" "driver module not affected"]])))))
:options [[nil "--git-ref REF" "The git ref to compute changed modules against"]
[nil "--is-master-or-release MASTER_OR_RELEASE"]
[nil "--pr-labels PR_LABELS" "Comma delimited labels on the pr"]
[nil "--skip SKIP"]
[nil "--github-output-only" "Only output GITHUB_OUTPUT format (no human-readable output)"]]
:requires [[clojure.string :as str]
[mage.modules]]
:task (task! (mage.modules/-main parsed))}
-install-merge-drivers

View File

@@ -4,7 +4,7 @@ import { readFileSync } from "fs";
import { glob } from "glob";
import { colorConfig } from "../../frontend/src/metabase/lib/colors/colors";
import { ALL_COLOR_NAMES } from "../../frontend/src/metabase/lib/colors";
/**
* This script finds css variables that are used but *never* defined in our codebase.
@@ -104,7 +104,7 @@ const main = () => {
allDefinitions.add(definition);
}
Object.keys(colorConfig).forEach((key) => {
ALL_COLOR_NAMES.forEach((key) => {
allDefinitions.add(`--mb-color-${key}`);
});

94
bin/openapi-check.sh Executable file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
OPENAPI_SPEC="resources/openapi/openapi.json"
AUTO_COMMIT="${AUTO_COMMIT:-false}"
# Check that $OPENAPI_SPEC matches the current Malli schema definitions
if [[ -n "${GITHUB_SHA:-}" ]]; then
echo "Running OpenAPI check on commit: $GITHUB_SHA"
fi
# Generate the current OpenAPI spec from Malli schemas using write-openapi-spec-to-file!
# This creates $OPENAPI_SPEC directly
# Using the generate-openapi-spec command from metabase.cmd.core
yarn generate-openapi
ret=$?
if [[ $ret -ne 0 ]]; then
exit $ret
fi
if [[ ! -f "$OPENAPI_SPEC" ]]; then
echo "Error: $OPENAPI_SPEC does not exist!"
exit 1
fi
# Check if there are any differences between generated and committed versions
if ! git diff --exit-code "$OPENAPI_SPEC" >/dev/null 2>&1; then
echo "OpenAPI schema changes detected:"
git diff "$OPENAPI_SPEC"
if [[ "$AUTO_COMMIT" == "true" ]]; then
echo ""
echo "Auto-committing changes..."
# Require PR_BRANCH to be set for auto-commit
if [[ -z "${PR_BRANCH:-}" ]]; then
echo "Error: PR_BRANCH environment variable must be set for auto-commit"
exit 1
fi
# Stash the generated spec from the merge commit
echo "Stashing generated OpenAPI spec from merge commit..."
cp "$OPENAPI_SPEC" "${OPENAPI_SPEC}.tmp"
# Checkout the actual PR branch (in case we're on a merge commit)
echo "Checking out branch: $PR_BRANCH"
git checkout -f "$PR_BRANCH"
# Restore the generated spec from the merge commit
mv -f "${OPENAPI_SPEC}.tmp" "$OPENAPI_SPEC"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "$OPENAPI_SPEC"
# Build commit message with SHAs if available
COMMIT_MSG="Update OpenAPI schema"
if [[ -n "${BASE_SHA:-}" ]] && [[ -n "${HEAD_SHA:-}" ]]; then
BASE_SHORT="${BASE_SHA:0:7}"
HEAD_SHORT="${HEAD_SHA:0:7}"
COMMIT_MSG="Update OpenAPI schema (${BASE_SHORT}...${HEAD_SHORT})"
fi
git commit -m "$COMMIT_MSG"
if ! git push origin "$PR_BRANCH"; then
echo "Error: Failed to push OpenAPI schema update to $PR_BRANCH."
exit 1
fi
echo "OpenAPI schema updated and pushed to $PR_BRANCH."
exit 0
fi
echo "Error: OpenAPI specs are out of date!"
echo "The schema in $OPENAPI_SPEC does not match the current Malli schema definitions."
echo ""
echo "This can happen when:"
echo " 1. You added/modified API endpoints but didn't regenerate the OpenAPI spec"
echo " 2. Your branch is behind master, which has new API endpoints"
echo ""
echo "To fix this:"
echo " 1. Pull the latest changes from master: git pull origin master"
echo " 2. Run 'yarn generate-openapi' to update $OPENAPI_SPEC"
echo " 3. Commit the updated openapi.json file"
echo ""
echo "Differences:"
git diff "$OPENAPI_SPEC" || true
exit 1
fi
echo "OpenAPI specs are up to date."
exit 0

View File

@@ -80,11 +80,11 @@
hiccup/hiccup {:mvn/version "2.0.0"} ; HTML templating
inflections/inflections {:mvn/version "0.15.0"} ; Clojure/Script library used for prularizing words
instaparse/instaparse {:mvn/version "1.5.0"} ; Make your own parser
io.github.camsaul/toucan2 {:mvn/version "1.0.569"}
io.github.camsaul/toucan2 {:mvn/version "1.0.570"}
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
:git/sha "ffc4edc482c057cd9412c62f018a41e9140c618b"
:git/url "https://github.com/eerohele/pp"}
io.github.metabase/macaw {:mvn/version "0.2.30"} ; Parse native SQL queries
io.github.metabase/macaw {:mvn/version "0.2.33"} ; Parse native SQL queries
io.netty/netty-codec-http {:mvn/version "4.2.9.Final"} ; override of transitive dep from aws s3
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
junegunn/grouper {:mvn/version "0.1.1"} ; Batch processing helper
@@ -362,6 +362,8 @@
"-Dmb.dangerous.unsafe.enable.testing.h2.connections.do.not.enable=true"
;; Ignore connection URI in favor of the MB_DB_FILE
"-Dmb.db.connection.uri="
;; Unset the premium token so that tests can set it programmatically
"-Dmb.premium.embedding.token="
;; Ignore config.yml
"-Dmb.config.file.path=__cypress__"
;; We use Snowplow micro in e2e tests

View File

@@ -306,6 +306,7 @@
{:current-user-id api/*current-user-id*
:current-user-perms @api/*current-user-permissions-set*
:is-superuser? api/*is-superuser?*
:is-data-analyst api/*is-data-analyst?*
:is-impersonated-user? (perms-util/impersonated-user?)
:is-sandboxed-user? (perms-util/impersonated-user?)
:archived false

File diff suppressed because one or more lines are too long

View File

@@ -224,6 +224,7 @@ config:
remote-sync-auto-import-rate: 5
remote-sync-check-changes-cache-ttl-seconds: 60
remote-sync-task-time-limit-ms: 300000
remote-sync-transforms: false
report-timezone: null
reset-token-ttl-hours: 48
retry-initial-interval: 500
@@ -271,6 +272,12 @@ config:
site-url: null
slack-app-token: null
slack-bug-report-channel: metabase-bugs
slack-connect-attribute-team-id: https://slack.com/team_id
slack-connect-authentication-mode: sso
slack-connect-client-id: null
slack-connect-client-secret: null
slack-connect-enabled: false
slack-connect-user-provisioning-enabled: true
smtp-override-enabled: false
source-address-header: X-Forwarded-For
sql-jdbc-fetch-size: 500

View File

@@ -1385,6 +1385,14 @@ Time-to-live in seconds for the remote changes check cache. Default is 60 second
The maximum amount of time a remote sync task will be given to complete.
### `MB_REMOTE_SYNC_TRANSFORMS`
- Type: boolean
- Default: `false`
- [Configuration file name](./config-file.md): `remote-sync-transforms`
Whether to sync transforms via remote-sync. When enabled, all transforms, transform tags, and transform jobs are synced as a single unit (all-or-nothing).
### `MB_REPORT_TIMEZONE`
- Type: string
@@ -1831,6 +1839,66 @@ Bot user OAuth token for connecting the Metabase Slack app. This should be used
The name of the channel where bug reports should be posted.
### `MB_SLACK_CONNECT_ATTRIBUTE_TEAM_ID`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: string
- Default: `https://slack.com/team_id`
- [Configuration file name](./config-file.md): `slack-connect-attribute-team-id`
Slack OIDC claim for the team/workspace ID.
### `MB_SLACK_CONNECT_AUTHENTICATION_MODE`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: string
- Default: `sso`
- [Configuration file name](./config-file.md): `slack-connect-authentication-mode`
Controls whether Slack can be used for SSO login or just account linking. Valid values: "sso" (default) or "link-only".
### `MB_SLACK_CONNECT_CLIENT_ID`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: string
- Default: `null`
- [Configuration file name](./config-file.md): `slack-connect-client-id`
Client ID for your Slack app. Get this from https://api.slack.com/apps.
### `MB_SLACK_CONNECT_CLIENT_SECRET`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: string
- Default: `null`
- [Configuration file name](./config-file.md): `slack-connect-client-secret`
Client Secret for your Slack app.
### `MB_SLACK_CONNECT_ENABLED`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: boolean
- Default: `false`
- [Configuration file name](./config-file.md): `slack-connect-enabled`
Is Slack Connect authentication configured and enabled?
### `MB_SLACK_CONNECT_USER_PROVISIONING_ENABLED`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: boolean
- Default: `true`
- [Configuration file name](./config-file.md): `slack-connect-user-provisioning-enabled`
When a user logs in via Slack Connect, create a Metabase account for them automatically if they don't have one.
### `MB_SLACK_FILES_CHANNEL [DEPRECATED]`
> DEPRECATED: 0.54.0

View File

@@ -11,12 +11,16 @@ Guest embeds are a way to embed basic Metabase components in your app without re
Even though you're not using SSO, guest embeds are still secure: Metabase will only load the embed if the request has a JWT signed with the secret shared between your app and your Metabase. The JWT also includes a reference to the resource to load (like the ID of the embedded item), and any values for parameters.
To restrict data in guest embeds for specific people or groups, use [locked parameters](./static-embedding-parameters.md#restricting-data-in-a-static-embed-with-locked-parameters).
To restrict data in guest embeds for specific people or groups, use [locked parameters](#locked-parameters).
## Turning on guest embedding in Metabase
1. Go to **Settings > Admin settings > Embedding > Guest embeds**.
2. Toggle **Enable guest embedding**.
The path to embedding settings depends on your Metabase version:
- **OSS**: **Admin settings > Embedding**
- **Starter/Pro/Enterprise**: **Admin settings > Embedding > Guest embeds**
Toggle **Enable guest embeds**.
## Creating a guest embed
@@ -94,37 +98,160 @@ Replace `YOUR_METABASE_SECRET_KEY` with your [embedding secret key](#regeneratin
You can set different attributes to enable/disable UI. Here are some example attributes:
| Attribute | Description |
| -------------------- | -------------------------------------------------------------------- |
| `token` | Required. The signed JWT token from your server. |
| `with-title` | Show or hide the title. Values: `"true"` or `"false"`. |
| `with-downloads` | Enable or disable downloads. Values: `"true"` or `"false"`. |
| `initial-parameters` | JSON string of parameter values. Example: `'{"category":["Gizmo"]}'` |
| Attribute | Description |
| -------------------- | --------------------------------------------------------------------- |
| `token` | Required. The signed JWT token from your server. |
| `with-title` | Show or hide the title. Values: `"true"` or `"false"`. |
| `with-downloads` | Enable or disable downloads. Values: `"true"` or `"false"`. |
| `initial-parameters` | JSON string of parameter values. Example: `'{"category":["Gizmo"]}'`. |
Attributes will differ based on the type of thing you're embedding. Guest embeds have fewer options that embeds that use SSO. See more on [components and their attributes](./components.md).
Attributes will differ based on the type of thing you're embedding. Guest embeds have fewer options than embeds that use SSO. See more on [components and their attributes](./components.md).
### Customizing appearance of guest embeds
Appearance settings available for guest embeds depend on your Metabase plan. If you're running Metabase OSS/Starter, you can select light or dark theme. If you're running Metabase Pro/Enterprise, you'll have access to granular customization options, see [Appearance](./appearance.md)
Appearance settings available for guest embeds depend on your Metabase plan. If you're running Metabase OSS/Starter, you can select light or dark theme. If you're running Metabase Pro/Enterprise, you'll have access to granular customization options, see [Appearance](./appearance.md).
## Editing parameters on an embedded question or dashboard
## Configuring parameters
If you change the [parameters](./static-embedding-parameters.md) of your embedded item:
Parameters are disabled by default, which also makes them hidden from end-users. You can configure each parameter to be:
1. After making your changes, copy the code Metabase generates.
2. Click **Publish** again.
3. Update the code on your server so that it matches the code generated by Metabase.
- **Disabled**: Hidden from end-users. Can't be set. This is the default.
- **[Editable](#editable-parameters)**: End-users can see and modify the parameter values.
- **[Locked](#locked-parameters)**: Hidden from end-users, set by your server (not the end-users) via the JWT.
To configure parameters:
1. Go to your embedded question or dashboard.
2. Click the **sharing icon** and select **Embed**.
3. Under **Parameters**, select the visibility option for each parameter, and optionally default value(s).
4. Click **Publish**.
### Editable parameters
When you set Editable parameters, you can set default values for the filters, but users can change these when viewing the question or dashboard.
**Server code**
On the server, you pass an empty `params` object:
```javascript
// you will need to install via 'npm install jsonwebtoken' or in your package.json
const jwt = require("jsonwebtoken");
const METABASE_SECRET_KEY = "YOUR_METABASE_SECRET_KEY";
const payload = {
resource: { dashboard: 10 },
params: {},
exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration
};
const token = jwt.sign(payload, METABASE_SECRET_KEY);
```
**Client code**
You set default parameters on the client side with the `initial-parameters` key.
```html
<script defer src="YOUR_METABASE_URL/app/embed.js"></script>
<script>
function defineMetabaseConfig(config) {
window.metabaseConfig = config;
}
</script>
<script>
defineMetabaseConfig({
isGuest: true,
instanceUrl: "YOUR_METABASE_URL",
});
</script>
<!--
THIS IS THE EXAMPLE!
NEVER HARDCODE THIS JWT TOKEN DIRECTLY IN YOUR HTML!
Fetch the JWT token from your backend and programmatically pass it to the 'metabase-dashboard'.
-->
<metabase-dashboard
token="YOUR SIGNED TOKEN"
with-title="true"
with-downloads="false"
initial-parameters='{"category":["Doohickey","Gizmo"]}'
>
</metabase-dashboard>
```
### Locked parameters
Locked parameters let you filter data without exposing the filter to the end-user. This is useful for restricting data based on who's viewing the embed (for example, showing each customer only their own data).
To use locked parameters, you need to:
1. Set the parameter to **Locked** in the embed settings.
2. Include the parameter value in your JWT token on the server.
3. Publish the item.
Here's an example of the server and client code that Metabase will generate if you lock a "category" parameter:
**Server code (Node.js)**
You set the locked parameter on the server, passing it in the token.
```javascript
// Install via 'npm install jsonwebtoken'
const jwt = require("jsonwebtoken");
const METABASE_SECRET_KEY = "YOUR_METABASE_SECRET_KEY";
const payload = {
resource: { dashboard: 10 },
params: {
category: ["Gadget"], // Set the locked parameter value to Gadget
},
exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration
};
const token = jwt.sign(payload, METABASE_SECRET_KEY);
```
**Client code (HTML)**
The parameter is set by the JWT:
```html
<script defer src="YOUR_METABASE_URL/app/embed.js"></script>
<script>
window.metabaseConfig = {
isGuest: true, // Must be set to guest
instanceUrl: "YOUR_METABASE_URL",
};
</script>
<!--
IMPORTANT: Never hardcode JWT tokens directly in your HTML!
Fetch the token from your backend and pass it to the component programmatically.
-->
<metabase-dashboard
token="YOUR_JWT_TOKEN"
with-title="true"
with-downloads="false"
></metabase-dashboard>
```
The end user won't see the "category" filter, but the dashboard will only show data for the "Gadget" category (or whatever you passed to the "category" array in the `params` object in the server payload you used to sign the JWT).
## Disabling embedding for a question or dashboard
You can find a list of all guest embeds of questions and dashboards from **Admin settings** > **Embedding** > **Guest embeds**.
1. Visit the embeddable question or dashboard.
2. Click the **sharing icon** (square with an arrow pointing to the top right).
3. Select **Embed**.
4. Select **Guest embedding**
5. Click **Unpublish**.
Admins can find a list of embedded items in **Admin settings > Embedding** (on Pro and Enterprise plans, check the **Guest embeds** tab).
## Removing the "Powered by Metabase" banner
![Powered by Metabase](./images/powered-by-metabase.png)
@@ -135,7 +262,7 @@ The banner appears on guest embeds created with Metabase's open-source version.
Your embedding secret key is used to sign JWTs for all of your embeds.
1. Go to **Admin** > **Embedding** > **Guest embeds**.
1. Go to **Admin settings > Embedding**. On Pro and Enterprise plans, check the **Guest embeds** tab.
2. Under **Regenerate secret key**, click **Regenerate key**.
This key is shared across all guest embeds. Whoever has access to this key could get access to all embedded artifacts, so keep this key secure. If you regenerate this key, you'll need to update your server code with the new key.
@@ -179,6 +306,12 @@ The signed JWT is generated using your [Metabase secret key](#regenerating-the-e
If you want to embed charts with additional interactive features, like [drill-down](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) and [self-service querying](../questions/query-builder/editor.md), see [Modular embedding](./modular-embedding.md).
## Using guest embeds with the SDK
If you're using the [Modular Embedding SDK](./sdk/introduction.md), and you also want to embed a question or dashboard using guest authentication, you'll still need to visit the item in your Metabase and publish the item. You can ignore the code the wizard generates, but in order for Metabase to know it's okay to serve the item, you need to publish it.
One limitation, however, is that you can only have one type of authentication per page of your app. For example, on a single page in your app, you can't have both one question using guest authentication and another question using SSO.
## Guest embed limitations
Because guest embeds don't require you to create a Metabase account for each person via SSO, Metabase can't know who is viewing the embed, and therefore can't give them access to all their data and all the cool stuff Metabase can do.
@@ -186,16 +319,16 @@ Because guest embeds don't require you to create a Metabase account for each per
Guest embeds can't take advantage of:
- [Row and column security](../permissions/row-and-column-security.md)
- [Database routing](../permissions/database-routing.md)
- [Drill-through](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through)
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md).
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md)
- [Query builder](../questions/query-builder/editor.md)
- [AI chat](./sdk/ai-chat.md).
- [AI chat](./sdk/ai-chat.md)
For those features, check out [Modular embedding with SSO](./modular-embedding.md).
## Further reading
- [Parameters for guest embeds](./static-embedding-parameters.md).
- [Reference apps repo](https://github.com/metabase/embedding-reference-apps).
- [Strategies for delivering customer-facing analytics](https://www.metabase.com/learn/metabase-basics/embedding/overview).
- [Publishing data visualizations to the web](https://www.metabase.com/learn/metabase-basics/embedding/charts-and-dashboards).

View File

@@ -162,10 +162,20 @@ To prevent people from saving changes to an interactive question, or from saving
![Query builder](../images/query-builder.png)
### Embed the visual query builder
You can embed the query builder for creating new questions by passing the `questionId="new"` prop to the `InteractiveQuestion` component. You can use the [`children` prop](#customizing-interactive-questions) to customize the layout for creating new questions.
```tsx
{% include_file "{{ dirname }}/snippets/questions/new-question.tsx" %}
```
### Embed the SQL editor
You can also embed the SQL editor to enable people to build questions with SQL by passing `questionId="new-native"` to the `InteractiveQuestion` component:
```tsx
{% include_file "{{ dirname }}/snippets/questions/new-native-question.tsx" %}
```
To customize the question editor's layout, use the `InteractiveQuestion` component [directly with a custom `children` prop](#customizing-interactive-questions).

View File

@@ -0,0 +1,18 @@
import React from "react";
import {
InteractiveQuestion,
MetabaseProvider,
defineMetabaseAuthConfig,
} from "@metabase/embedding-sdk-react";
const authConfig = defineMetabaseAuthConfig({
metabaseInstanceUrl: "https://your-metabase.example.com",
});
export default function App() {
return (
<MetabaseProvider authConfig={authConfig}>
<InteractiveQuestion questionId="new-native" />
</MetabaseProvider>
);
}

View File

@@ -17,6 +17,9 @@ const options = {
GENERATE_SNAPSHOTS: true,
JAR_PATH: undefined,
...booleanify(process.env),
// If this token is present in your env when Cypress runs, it's a mistake.
// Cypress needs to start from a clean slate in order to set the token programmatically.
MB_PREMIUM_EMBEDDING_TOKEN: undefined,
};
process.env = unBooleanify(options);

View File

@@ -125,22 +125,22 @@ describe("snapshots", () => {
// groups
cy.request("POST", "/api/permissions/group", { name: "collection" }).then(
({ body }) => {
expect(body.id).to.eq(COLLECTION_GROUP); // 4
expect(body.id).to.eq(COLLECTION_GROUP); // 5
},
);
cy.request("POST", "/api/permissions/group", { name: "data" }).then(
({ body }) => {
expect(body.id).to.eq(DATA_GROUP); // 5
expect(body.id).to.eq(DATA_GROUP); // 6
},
);
cy.request("POST", "/api/permissions/group", { name: "readonly" }).then(
({ body }) => {
expect(body.id).to.eq(READONLY_GROUP); // 6
expect(body.id).to.eq(READONLY_GROUP); // 7
},
);
cy.request("POST", "/api/permissions/group", { name: "nosql" }).then(
({ body }) => {
expect(body.id).to.eq(NOSQL_GROUP); // 7
expect(body.id).to.eq(NOSQL_GROUP); // 8
},
);

View File

@@ -46,6 +46,67 @@ const assetsResolverPlugin = {
},
};
// Plugin to allow loading the ClojureScript bundle only when it exists
// on the filesystem. This is to avoid breaking the build when the bundle
// is not present.
//
// If the bundle is not present, tests relying on it will fail, but the Cypress
// process will be allowed to start.
//
// The error will look like this:
//
// Dynamic require of "cljs/metabase.types.core" is not supported
//
// This allows us to avoid building the ClojureScript bundle when we don't need to (like in certain CI tasks).
const virtualClojureScriptPlugin = {
name: "virtualClojureScriptBundle",
setup(build) {
let cljsDir = null;
async function isDirectory(path) {
try {
const stat = await fs.promises.stat(path);
return stat.isDirectory();
} catch (e) {
return false;
}
}
build.onStart(async () => {
const baseDir = path.join(__dirname, "../../target");
const devDir = path.join(baseDir, "cljs_dev");
const prodDir = path.join(baseDir, "cljs_prod");
const [devDirExists, prodDirExists] = await Promise.all([
isDirectory(devDir),
isDirectory(prodDir),
]);
if (prodDirExists) {
console.log("ClojureScript production bundle found");
cljsDir = prodDir;
} else if (devDirExists) {
console.log("ClojureScript development bundle found");
cljsDir = devDir;
} else {
console.log("No ClojureScript bundle found, not loading bundle");
cljsDir = null;
}
});
build.onResolve({ filter: /^cljs\// }, async (args) => {
if (cljsDir === null) {
return {
path: args.path,
external: true,
};
}
const unnested = args.path.split(path.sep).slice(1).join(path.sep);
return { path: path.join(cljsDir, unnested + ".js") };
});
},
};
const defaultConfig = {
// This is the functionality of the old cypress-plugins.js file
setupNodeEvents(cypressOn, config) {
@@ -75,7 +136,11 @@ const defaultConfig = {
loader: {
".svg": "text",
},
plugins: [NodeModulesPolyfillPlugin(), assetsResolverPlugin],
plugins: [
NodeModulesPolyfillPlugin(),
assetsResolverPlugin,
virtualClojureScriptPlugin,
],
sourcemap: "inline",
}),
);

View File

@@ -43,14 +43,15 @@ export const SAMPLE_DB_TABLES = {
export const USER_GROUPS = {
ALL_USERS_GROUP: 1,
ADMIN_GROUP: 2,
COLLECTION_GROUP: 4,
DATA_GROUP: 5,
READONLY_GROUP: 6,
NOSQL_GROUP: 7,
COLLECTION_GROUP: 5,
DATA_GROUP: 6,
READONLY_GROUP: 7,
NOSQL_GROUP: 8,
};
export const TENANT_USER_GROUPS = {
export const MAGIC_USER_GROUPS = {
EXTERNAL_USERS_GROUP: 3,
DATA_ANALYSTS_GROUP: 4,
};
const {

View File

@@ -156,6 +156,10 @@ export const ALL_EXTERNAL_USERS_GROUP_ID = _.findWhere(
},
)?.id;
export const DATA_ANALYSTS_GROUP_ID = _.findWhere(SAMPLE_INSTANCE_DATA.groups, {
magic_group_type: "data-analyst",
})?.id;
export const COLLECTION_GROUP_ID = _.findWhere(SAMPLE_INSTANCE_DATA.groups, {
name: "collection",
}).id;

View File

@@ -38,11 +38,12 @@ export const DataStudio = {
},
PythonLibrary: {
header: () => cy.findByTestId("python-library-header"),
editor: () => cy.findByTestId("python-editor"),
},
Snippets: {
newPage: newSnippetPage,
editPage: editSnippetPage,
nameInput: () => newSnippetPage().findByPlaceholderText("New SQL snippet"),
nameInput: () => newSnippetPage().findByDisplayValue("New SQL snippet"),
descriptionInput: () => cy.findByPlaceholderText("No description"),
saveButton: () => cy.findByRole("button", { name: "Save" }),
cancelButton: () => cy.findByRole("button", { name: "Cancel" }),
@@ -131,21 +132,4 @@ export const DataStudio = {
collectionItem: (name: string) =>
libraryPage().findAllByTestId("collection-name").contains(name),
},
Tasks: {
visitUnreferencedEntities: () =>
cy.visit("/data-studio/tasks/unreferenced"),
list: () => cy.findByTestId("dependency-list"),
searchInput: () => cy.findByTestId("dependency-list-search-input"),
filterButton: () => cy.findByTestId("dependency-list-filter-button"),
sidebar: () => cy.findByTestId("dependency-list-sidebar"),
Sidebar: {
get: () => cy.findByTestId("dependency-list-sidebar"),
header: () => cy.findByTestId("dependency-list-sidebar-header"),
locationInfo: () => cy.findByRole("region", { name: "Location" }),
errorInfo: (label: string) => cy.findByRole("region", { name: label }),
creationInfo: () =>
cy.findByRole("region", { name: "Creator and last editor" }),
},
},
};

View File

@@ -1,7 +0,0 @@
export const DependencyGraph = {
graph: () => cy.findByTestId("dependency-graph"),
entryButton: () => cy.findByTestId("graph-entry-button"),
entrySearchInput: () => cy.findByTestId("graph-entry-search-input"),
selectionButton: () => cy.findByTestId("graph-selection-button"),
dependencyPanel: () => cy.findByTestId("graph-dependency-panel"),
};

View File

@@ -0,0 +1,31 @@
export const DependencyGraph = {
graph: () => cy.findByTestId("dependency-graph"),
entryButton: () => cy.findByTestId("graph-entry-button"),
entrySearchInput: () => cy.findByTestId("graph-entry-search-input"),
selectionButton: () => cy.findByTestId("graph-selection-button"),
dependencyPanel: () => cy.findByTestId("graph-dependency-panel"),
};
export const DependencyDiagnostics = {
visitBrokenDependencies: () =>
cy.visit("/data-studio/dependency-diagnostics/broken"),
visitUnreferencedEntities: () =>
cy.visit("/data-studio/dependency-diagnostics/unreferenced"),
list: () => cy.findByTestId("dependency-list"),
searchInput: () => cy.findByTestId("dependency-list-search-input"),
filterButton: () => cy.findByTestId("dependency-filter-button"),
sidebar: () => cy.findByTestId("dependency-list-sidebar"),
Sidebar: {
get: () => cy.findByTestId("dependency-list-sidebar"),
header: () => cy.findByTestId("dependency-list-sidebar-header"),
locationSection: () => cy.findByRole("region", { name: "Location" }),
transformSection: () => cy.findByRole("region", { name: "Transform" }),
missingColumnsSection: () =>
cy.findByRole("region", { name: "Missing columns" }),
creationSection: () =>
cy.findByRole("region", { name: "Creator and last editor" }),
brokenDependentsSection: () =>
cy.findByRole("region", { name: "Broken dependents" }),
},
};

View File

@@ -47,6 +47,8 @@ export const exportFromDashcard = (format: string) => {
cy.findByText(format).click();
cy.findByTestId("download-results-button").click();
});
cy.findByTestId("status-root-container").should("contain", "Downloading");
};
/**

View File

@@ -4,10 +4,20 @@ export const embedModalContent = () =>
export const embedModalEnableEmbeddingCard = () =>
cy.findByTestId("enable-embedding-card");
export const embedModalEnableEmbedding = () =>
embedModalEnableEmbeddingCard().within(() => {
cy.findByText(/(Agree and (continue|enable)|Enable)/).click();
export const embedModalEnableEmbedding = () => {
cy.get("body").then(($body) => {
const isEmbeddingDisabled =
$body.find('[data-testid="enable-embedding-card"]').length > 0;
if (isEmbeddingDisabled) {
embedModalEnableEmbeddingCard().within(() => {
cy.findByText(
/(Agree and (continue|enable)|Enable and continue)/,
).click();
});
}
});
};
export const legacyStaticEmbeddingButton = () =>
cy.findByTestId("legacy-static-embedding-button");

View File

@@ -10,7 +10,14 @@ export function selectNativeEditorDataSource(name: string) {
popover().findByText(name).click();
}
function clickOnRun() {
cy.findByTestId("native-query-editor-container").within(() =>
cy.findByTestId("run-button").click(),
);
}
export const NativeEditor = codeMirrorHelpers("native-query-editor", {
dataSource: nativeEditorDataSource,
selectDataSource: selectNativeEditorDataSource,
clickOnRun,
});

View File

@@ -213,12 +213,12 @@ export const waitForTask = (
if (retries > 3) {
throw Error(`Too many retries waiting for ${taskName}`);
}
cy.wait("@currentTask").then(({ response }) => {
return cy.wait("@currentTask").then(({ response }) => {
const { body } = response || {};
if (body?.sync_task_type !== taskName) {
waitForTask({ taskName });
return waitForTask({ taskName });
} else if (body?.status !== "successful") {
waitForTask({ taskName }, retries + 1);
return waitForTask({ taskName }, retries + 1);
}
});
};

View File

@@ -3,6 +3,11 @@ interface User {
first_name?: string | null;
last_name?: string | null;
}
export function setUserAsAnalyst(userId: number) {
cy.request("PUT", `/api/user/${userId}`, { is_data_analyst: true });
}
/**
* Get user's full name, or an email address if name is not available.
*/

View File

@@ -1,4 +1,5 @@
export * from "./api";
export * from "./mbql";
export * from "./e2e-action-helpers";
export * from "./e2e-ad-hoc-question-helpers";
export * from "./e2e-api-key-helpers";
@@ -16,7 +17,7 @@ export * from "./e2e-dashboard-visualizer-helpers";
export * from "./e2e-database-metadata-helpers";
export * from "./e2e-datamodel-helpers";
export * from "./e2e-data-studio-helpers";
export * from "./e2e-dependency-graph-helpers";
export * from "./e2e-dependency-helpers";
export * from "./e2e-detail-view-helpers";
export * from "./e2e-dimension-list-helpers";
export * from "./e2e-document-helpers";

View File

@@ -0,0 +1,6 @@
export {
getMetadataProvider,
createQuery,
createNativeQuery,
} from "./wrappers";
export { createCardWithQuery, createCardWithNativeQuery } from "./question";

View File

@@ -0,0 +1,86 @@
// WARNING:
// This module should not be imported directly in cypress
// harnesses, use an dynamic import instead.
import { getMetadataWithoutSettings as getMetadataFromState } from "metabase/selectors/metadata";
import * as Lib from "metabase-lib";
import type {
Card,
CardId,
Database,
Table,
TableId,
} from "metabase-types/api";
import { createMockEntitiesState } from "../../../../frontend/test/__support__/store";
import { SAMPLE_DB_ID, SAMPLE_DB_TABLES } from "../../cypress_data";
import type { GetMetadataOpts } from "./types";
const DEFAULT_TABLE_IDS: TableId[] = [
SAMPLE_DB_TABLES.STATIC_ORDERS_ID,
SAMPLE_DB_TABLES.STATIC_PEOPLE_ID,
SAMPLE_DB_TABLES.STATIC_PRODUCTS_ID,
SAMPLE_DB_TABLES.STATIC_REVIEWS_ID,
];
const DEFAULT_CARD_IDS: CardId[] = [];
/**
* Returns a Lib.MetadataProvider instance containing metadata for the
* provided database, tables and cards.
*
* WARNING:
* DO NOT USE THIS DIRECTLY IN TESTS.
* Use the `getMetadataProvider` from ./wrappers" instead.
*
* NOTE:
* This function uses plain Promises and Promise.all to avoid type issues
* with Cypress. The result needs to wrapped in cy.wrap() to be correctly usable
* in Cypress tests.
*/
export async function getMetadataProvider({
databaseId = SAMPLE_DB_ID,
...rest
}: GetMetadataOpts = {}) {
const metadata = await getMetadata({ databaseId, ...rest });
return Lib.metadataProvider(databaseId, metadata);
}
async function getMetadata({
databaseId = SAMPLE_DB_ID,
tableIds = DEFAULT_TABLE_IDS,
cardIds = DEFAULT_CARD_IDS,
}: GetMetadataOpts = {}) {
// Just one database for now, but wrapped to get the types to work
const databaseIds = [databaseId];
const [databases, tables, cards] = await Promise.all([
// databases
Promise.all(
databaseIds.map((databaseId) =>
GET<Database>(`/api/database/${databaseId}`),
),
),
// cards
Promise.all(
tableIds.map((tableId) =>
GET<Table>(`/api/table/${tableId}/query_metadata`),
),
),
// tables
Promise.all(cardIds.map((cardId) => GET<Card>(`/api/card/${cardId}`))),
]);
const entities = createMockEntitiesState({
databases,
tables,
questions: cards,
});
return getMetadataFromState({ entities });
}
async function GET<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json();
}

View File

@@ -0,0 +1,104 @@
import type * as Lib from "metabase-lib";
import type { CreateTestQueryOpts } from "metabase-lib/test-helpers";
import type { DatabaseId } from "metabase-types/api";
import type { QuestionDetails } from "../api";
import { type Options, question } from "../api/createQuestion";
import type { GetMetadataOpts } from "./types";
import { createNativeQuery, createQuery } from "./wrappers";
/**
* Create a card using the provided details and a MBQL query.
*
* @param details.metadata -
* The metadata provider to use for the query,
* or options used to create one on the fly.
* If no metadata is provided, the query will be created with
* the default metadata provider.
*
* @example
* // If no metadata is provided, the card will be created with
* // the default metadata provider.
* H.createTestQuestion({
* name: "My question",
* query: {
* databaseId: SAMPLE_DATABASE_ID,
* stages: [
* ...
* ]
* }
* })
* })
*
* @example
* // Provide custom metadata entities relevant to your query.
* H.createTestQuestion({
* name: "My question",
* databaseId: SAMPLE_DATABASE_ID,
* query: "SELECT * FROM orders"
* })
* })
*
*/
type CardWithQueryDetails = Omit<QuestionDetails, "dataset_query"> & {
metadata?: GetMetadataOpts | Lib.MetadataProvider;
query: CreateTestQueryOpts;
};
export function createCardWithQuery(
{ metadata = {}, query, ...details }: CardWithQueryDetails,
options: Options = {},
) {
return createQuery(metadata, query).then((dataset_query) =>
question({ ...details, dataset_query }, options),
);
}
type CardWithNativeQueryDetails = Omit<QuestionDetails, "dataset_query"> & {
metadata?: GetMetadataOpts | Lib.MetadataProvider;
databaseId: DatabaseId;
query: string;
};
/**
* Create a card using the provided details and a native query.
*
* @param details.metadata -
* The metadata provider to use for the query,
* or options used to create one on the fly.
* If no metadata is provided, the query will be created with
* the default metadata provider.
*
* @example
* // If no metadata is provided, the card will be created with
* // the default metadata provider.
* H.createCardWithNativeQuery({
* name: "My question",
* databaseId: SAMPLE_DATABASE_ID,
* query: "SELECT * FROM orders"
* })
* })
*
* @example
* // Provide custom metadata entities relevant to your query.
* H.createCardWithNativeQuery({
* name: "My question",
* databaseId: SAMPLE_DATABASE_ID,
* metadata: {
* databaseId: SAMPLE_DATABASE_ID,
* cardIds: [SAMPLE_CARD_ID],
* },
* query: "SELECT * FROM orders"
* })
* })
*/
export function createCardWithNativeQuery(
{ metadata = {}, databaseId, query, ...details }: CardWithNativeQueryDetails,
options: Options = {},
) {
return createNativeQuery(metadata, databaseId, query).then((dataset_query) =>
question({ ...details, dataset_query }, options),
);
}

View File

@@ -0,0 +1,7 @@
import type { CardId, DatabaseId, TableId } from "metabase-types/api";
export type GetMetadataOpts = {
databaseId?: DatabaseId;
tableIds?: TableId[];
cardIds?: CardId[];
};

View File

@@ -0,0 +1,103 @@
import type * as Lib from "metabase-lib";
import type { CreateTestQueryOpts } from "metabase-lib/test-helpers";
import type { DatabaseId, OpaqueDatasetQuery } from "metabase-types/api";
import type { GetMetadataOpts } from "./types";
/**
* Returns a Lib.MetadataProvider instance containing metadata for the
* entities defined in opts.
*
* @param opts.databaseId - The database to fetch metadata for
* @param opts.tableIds - The tables to fetch metadata for
* @param opts.cardIds - Cards to fetch metadata for
*/
export function getMetadataProvider(
opts?: GetMetadataOpts,
): Cypress.Chainable<Lib.MetadataProvider> {
return (
cy
// we need to log in before we can get metadata
.getCookie("metabase.SESSION_ID")
.then(() => _getMetadataProvider({ ...opts }))
);
}
// Helper function which calls getMetadataProvider from ./metadata-provider
// This module needs to imported lazily not to crash Cypress and to avoid importing
// cljs in tests that don't need it.
async function _getMetadataProvider(
opts?: GetMetadataOpts,
): Promise<Lib.MetadataProvider> {
const { getMetadataProvider } = await import("./metadata-provider");
return getMetadataProvider(opts);
}
/**
* Build an MBQL query using a custom DSL that simplifies field selection and creation.
*
* @param metadataProvider - A Lib.MetadataProvider instance or a GetMetadataOpts object that will be user to instantiate a Lib.MetadataProvider
* @param opts - The details of the query to create
*/
export function createQuery(
metadataProvider: Lib.MetadataProvider | GetMetadataOpts,
opts: CreateTestQueryOpts,
): Cypress.Chainable<OpaqueDatasetQuery> {
return ensureProvider(metadataProvider).then((provider) => {
return createTestJsQuery(provider, opts);
});
}
// Helper function which calls createTestJsQuery from metabase-lib/test-helpers.
// This module needs to imported lazily not to crash Cypress and to avoid importing
// cljs in tests that don't need it.
async function createTestJsQuery(
metadataProvider: Lib.MetadataProvider,
opts: CreateTestQueryOpts,
): Promise<OpaqueDatasetQuery> {
const { createTestJsQuery } = await import("metabase-lib/test-helpers");
return createTestJsQuery(metadataProvider, opts);
}
/**
* Build a native MBQL query using a custom DSL that simplifies field selection and creation.
*
* @param metadataProvider - A Lib.MetadataProvider instance or a GetMetadataOpts object that will be user to instantiate a Lib.MetadataProvider
* @param databaseId - The database the query is for
* @param query - The native query string
*/
export function createNativeQuery(
metadataProvider: Lib.MetadataProvider | GetMetadataOpts,
databaseId: DatabaseId,
query: string,
): Cypress.Chainable<OpaqueDatasetQuery> {
return ensureProvider(metadataProvider).then((provider) =>
createTestNativeJsQuery(provider, databaseId, query),
);
}
// Helper function which calls createTestNativeJsQuery from metabase-lib/test-helpers.
// This module needs to imported lazily not to crash Cypress and to avoid importing
// cljs in tests that don't need it.
async function createTestNativeJsQuery(
metadataProvider: Lib.MetadataProvider,
databaseId: DatabaseId,
query: string,
) {
const { createTestNativeJsQuery } = await import("metabase-lib/test-helpers");
return createTestNativeJsQuery(metadataProvider, databaseId, query);
}
function ensureProvider(
providerOrOpts: GetMetadataOpts | Lib.MetadataProvider,
): Cypress.Chainable<Lib.MetadataProvider> {
return isMetadataProvider(providerOrOpts)
? cy.wrap(providerOrOpts)
: getMetadataProvider(providerOrOpts);
}
function isMetadataProvider(
opts: GetMetadataOpts | Lib.MetadataProvider,
): opts is Lib.MetadataProvider {
return "cache" in opts;
}

File diff suppressed because it is too large Load Diff

View File

@@ -47,4 +47,59 @@ describe("scenarios > embedding-sdk > interactive-question", () => {
H.assertTableRowsCount(4);
});
});
it("should be able to edit a native question with the QueryEditor", () => {
cy.intercept("GET", "/api/database").as("schema");
cy.get("@sqlQuestionId").then((sqlQuestionId) => {
mountSdkContent(
<InteractiveQuestion questionId={sqlQuestionId} withParameters />,
);
});
getSdkRoot().within(() => {
cy.findByText("SQL Orders").should("be.visible");
H.assertTableRowsCount(5);
cy.findByTestId("notebook-button").click();
cy.findByTestId("native-query-editor").should("be.visible");
cy.wait("@schema");
H.NativeEditor.value().should("equal", "select * from ORDERS limit 5");
cy.findByRole("textbox").should("be.visible").click();
H.NativeEditor.type("{movetoend}{backspace}10");
H.NativeEditor.clickOnRun();
cy.button("Visualize").should("be.visible").click();
H.assertTableRowsCount(10);
});
});
it("should be able to create a native question with the QueryEditor", () => {
cy.intercept("GET", "/api/database?can-query=true").as("schema");
mountSdkContent(
<InteractiveQuestion questionId="new-native" withParameters />,
);
getSdkRoot().within(() => {
cy.findByTestId("native-query-editor").should("be.visible");
cy.findByLabelText("placeholder SELECT * FROM TABLE_NAME").should(
"be.visible",
);
cy.wait("@schema");
H.NativeEditor.type("SELECT * from ORDERS LIMIT 10");
H.NativeEditor.clickOnRun();
cy.button("Visualize").should("be.visible").click();
H.assertTableRowsCount(10);
});
});
});

View File

@@ -110,7 +110,7 @@ describe("scenarios > admin > people", () => {
cy.findByRole("link", { name: /group/i }).click();
cy.findByRole("table").findByRole("link", { name: /data/i }).click();
cy.findByRole("table").findByRole("link", { name: /data$/i }).click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("1 member");

View File

@@ -3,7 +3,7 @@ import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
describe("issue 26470", { tags: "@external" }, () => {
beforeEach(() => {
H.restore("postgres_12");
H.restore("postgres-writable");
cy.signInAsAdmin();
cy.request("POST", "/api/persist/enable");
});

View File

@@ -374,21 +374,24 @@ describe("Remote Sync", () => {
cy.button(/Push changes/).click();
});
H.waitForTask({ taskName: "export" });
H.waitForTask({ taskName: "export" }).then(() => {
H.getGitSyncControls().should("contain.text", NEW_BRANCH);
H.collectionTable().within(() => {
cy.findByText("Orders").should("exist");
cy.findByText(REMOTE_QUESTION_NAME).should("exist");
});
H.getGitSyncControls().should("contain.text", NEW_BRANCH);
H.collectionTable().within(() => {
cy.findByText("Orders").should("exist");
cy.findByText(REMOTE_QUESTION_NAME).should("exist");
});
H.modal().findByText("Pushing to Git").should("not.exist");
H.getSwitchBranchOption().click();
H.popover().findByRole("option", { name: "main" }).click();
H.getSwitchBranchOption().click();
H.popover().findByRole("option", { name: "main" }).click();
H.waitForTask({ taskName: "import" });
H.collectionTable().within(() => {
cy.findByText("Orders").should("not.exist");
cy.findByText(REMOTE_QUESTION_NAME).should("exist");
H.waitForTask({ taskName: "import" }).then(() => {
H.collectionTable().within(() => {
cy.findByText("Orders").should("not.exist");
cy.findByText(REMOTE_QUESTION_NAME).should("exist");
});
});
});
});

View File

@@ -139,17 +139,14 @@ describe("issue 14636", () => {
"have.value",
"field values scanning",
);
cy.findByPlaceholderText("Filter by status").should(
"have.value",
"Success",
);
getFilterByStatus().should("have.value", "Success");
cy.findAllByTestId("task").should("have.length", 1);
cy.findByTestId("task")
.should("contain.text", "field values scanning")
.and("contain.text", "Sample Database")
.and("contain.text", "Success");
cy.findByPlaceholderText("Filter by status").click();
getFilterByStatus().click();
H.popover().findByText("Failed").click();
cy.location("search").should(
"eq",
@@ -161,12 +158,9 @@ describe("issue 14636", () => {
"No results",
);
cy.findByPlaceholderText("Filter by status")
.parent()
.findByLabelText("Clear")
.click();
getFilterByStatus().parent().findByLabelText("Clear").click();
cy.location("search").should("eq", "?task=field+values+scanning");
cy.findByPlaceholderText("Filter by status").should("have.value", "");
getFilterByStatus().should("have.value", "");
cy.findAllByTestId("task").should("have.length", 1);
cy.findByTestId("task")
.should("contain.text", "field values scanning")
@@ -184,14 +178,14 @@ describe("issue 14636", () => {
cy.log("should reset pagination when changing filters");
cy.visit("/admin/tools/tasks/list?page=1");
cy.findByPlaceholderText("Filter by status").click();
getFilterByStatus().click();
H.popover().findByText("Success").click();
cy.location("search").should("eq", "?status=success");
cy.log("should remove invalid query params");
cy.visit("/admin/tools/tasks/list?status=foobar");
cy.location("search").should("eq", "");
cy.findByPlaceholderText("Filter by status").should("have.value", "");
getFilterByStatus().should("have.value", "");
});
});
@@ -244,7 +238,7 @@ describe("scenarios > admin > tools > tasks", () => {
cy.window().then((window) => {
cy.stub(window.navigator.clipboard, "writeText").resolves();
});
cy.icon("copy").click();
cy.findByTestId("code-container").icon("copy").click();
cy.window()
.its("navigator.clipboard.writeText")
.should("be.calledWith", formattedTaskJson);
@@ -832,54 +826,40 @@ describe("scenarios > admin > tools > task runs filtering", () => {
cy.wait("@getTaskRuns");
cy.log("Filter by run type");
cy.findByPlaceholderText("Filter by run type").click();
getFilterByRun().click();
H.popover().findByText("Sync").click();
cy.location("search").should("contain", "run-type=sync");
cy.wait("@getTaskRuns");
cy.log("Filter by started at");
getFilterByStartedAt().click();
H.popover().findByText("Previous 30 days").click();
cy.location("search").should("contain", "started-at=past30days");
cy.wait("@getTaskRuns");
cy.wait("@getEntities");
cy.log("Filter by entity");
cy.findByPlaceholderText("Filter by entity").click();
getFilterByEntity().click();
H.popover().findByText("Sample Database").click();
cy.location("search").should("contain", "entity-type=database");
cy.location("search").should("contain", "entity-id=1");
cy.wait("@getTaskRuns");
cy.log("Filter by status");
cy.findByPlaceholderText("Filter by status").click();
getFilterByStatus().click();
H.popover().findByText("Success").click();
cy.location("search").should("contain", "status=success");
cy.wait("@getTaskRuns");
cy.log("Clear all filters");
cy.findByPlaceholderText("Filter by run type")
.parent()
.findByLabelText("Clear")
.click();
cy.findByPlaceholderText("Filter by status")
.parent()
.findByLabelText("Clear")
.click();
getFilterByRun().parent().findByLabelText("Clear").click();
getFilterByStartedAt().parent().findByLabelText("Clear").click();
getFilterByStatus().parent().findByLabelText("Clear").click();
cy.location("search").should("eq", "");
});
it("entity picker should be disabled/enabled based on run type and entities availability", () => {
it("entity picker should be disabled/enabled based on run type, started at and entities availability", () => {
cy.visit("/admin/tools/tasks/runs");
cy.wait("@getTaskRuns");
cy.log("Should be disabled when no run type is selected");
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
cy.log("Should show tooltip 'Select a run type first' when hovering");
cy.findByPlaceholderText("Filter by entity").trigger("mouseenter", {
force: true,
});
cy.findByRole("tooltip").should("have.text", "Select a run type first");
cy.findByPlaceholderText("Filter by entity").trigger("mouseleave", {
force: true,
});
cy.log("Should show loader while loading entities");
cy.intercept("GET", "/api/task/runs/entities?*", {
body: [
{
@@ -891,10 +871,24 @@ describe("scenarios > admin > tools > task runs filtering", () => {
delay: 500,
}).as("getEntitiesDelayed");
cy.findByPlaceholderText("Filter by run type").click();
cy.log("Should be disabled when no run type is selected");
getFilterByEntity().should("be.disabled");
assertFilterByEntityTooltipText("Select a run type first");
getFilterByRun().click();
H.popover().findByText("Sync").click();
cy.findByPlaceholderText("Filter by entity")
cy.log("Should be still disabled until started at is selected");
getFilterByEntity().should("be.disabled");
cy.log("Should show tooltip 'Select a start time' when hovering");
assertFilterByEntityTooltipText("Select a start time first");
getFilterByStartedAt().click();
H.popover().findByText("Previous 30 days").click();
cy.log("Should show loader while loading entities");
getFilterByEntity()
.should("be.disabled")
.closest(".mb-mantine-Select-root")
.find(".mb-mantine-Loader-root")
@@ -903,30 +897,56 @@ describe("scenarios > admin > tools > task runs filtering", () => {
cy.wait("@getEntitiesDelayed");
cy.log("Should be enabled after entities are loaded");
cy.findByPlaceholderText("Filter by entity").should("not.be.disabled");
getFilterByEntity().should("not.be.disabled");
cy.log("Should clear and disable entity filter when run type is cleared");
cy.findByPlaceholderText("Filter by run type")
.parent()
.findByLabelText("Clear")
.click();
getFilterByRun().parent().findByLabelText("Clear").click();
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
cy.findByPlaceholderText("Filter by entity").should("have.value", "");
getFilterByEntity().should("be.disabled");
getFilterByEntity().should("have.value", "");
cy.log("Should clear and disable entity filter when started at is cleared");
getFilterByRun().click();
H.popover().findByText("Sync").click();
getFilterByStartedAt().parent().findByLabelText("Clear").click();
getFilterByEntity().should("be.disabled");
getFilterByEntity().should("have.value", "");
cy.log("Should show tooltip 'No entities available' when no entities");
cy.intercept("GET", "/api/task/runs/entities?*", {
body: [],
}).as("getEmptyEntities");
cy.findByPlaceholderText("Filter by run type").click();
getFilterByRun().click();
H.popover().findByText("Alert").click();
getFilterByStartedAt().click();
H.popover().findByText("Previous 30 days").click();
cy.wait("@getEmptyEntities");
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
cy.findByPlaceholderText("Filter by entity").trigger("mouseenter", {
force: true,
});
cy.findByRole("tooltip").should("have.text", "No entities available");
getFilterByEntity().should("be.disabled");
assertFilterByEntityTooltipText("No entities available");
});
});
function getFilterByRun() {
return cy.findByPlaceholderText("Filter by run type");
}
function getFilterByStartedAt() {
return cy.findByPlaceholderText("Filter by started at");
}
function getFilterByEntity() {
return cy.findByPlaceholderText("Filter by entity");
}
function getFilterByStatus() {
return cy.findByPlaceholderText("Filter by status");
}
function assertFilterByEntityTooltipText(text: string) {
getFilterByEntity().trigger("mouseenter", {
force: true,
});
cy.findByRole("tooltip").should("have.text", text);
}

View File

@@ -203,6 +203,7 @@ describe("issue 56567", () => {
["All Users", allUsersPermission],
["collection", "Curate"],
["data", "No access"],
["Data Analysts", "No access"],
["nosql", "No access"],
["readonly", "View"],
];

View File

@@ -1525,6 +1525,114 @@ SELECT 'group_2', 'sub_group_2', 52, 'group_2__sub_group_2';
const scalarContainer = () => cy.findByTestId("scalar-container");
const previousValue = () => cy.findByTestId("scalar-previous-value");
describe("issue 67432", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("should copy sorted table data in correct sorted order (metabase#67432)", () => {
H.grantClipboardPermissions();
const ROWS_LIMIT = 5;
const questionDetails = {
name: "67432 Question",
query: {
"source-table": PRODUCTS_ID,
fields: [
["field", PRODUCTS.ID, null],
["field", PRODUCTS.TITLE, null],
["field", PRODUCTS.CATEGORY, null],
],
limit: ROWS_LIMIT,
},
};
H.createQuestionAndDashboard({
questionDetails,
cardDetails: {
size_x: 16,
size_y: 10,
},
}).then(({ body: { dashboard_id } }) => {
H.visitDashboard(dashboard_id);
});
// Wait for table to load
H.tableInteractiveBody().should("be.visible");
// Sort by Category column (descending first click)
H.tableHeaderClick("Category");
// Wait for sort to apply - the sort icon should appear
H.tableHeaderColumn("Category")
.closest("[data-testid=header-cell]")
.icon("chevrondown")
.should("exist");
// Collect the visual order of categories from the table
const visualCategories = [];
H.tableInteractiveBody()
.find('[data-column-id="CATEGORY"]')
.each(($cell) => {
visualCategories.push($cell.text());
})
.then(() => {
// Select multiple cells across rows by dragging
const getNonPKCells = () =>
H.tableInteractiveBody().find(
'[data-selectable-cell]:not([data-column-id="ID"])',
);
// Select cells in first two rows (4 cells: Title+Category for 2 rows)
getNonPKCells()
.eq(0)
.trigger("mousedown", { which: 1 })
.then(() => {
const lastCellIndex = ROWS_LIMIT * 2 - 1;
getNonPKCells()
.should("have.length", ROWS_LIMIT * 2)
.eq(lastCellIndex)
.trigger("mouseover", { buttons: 1 });
getNonPKCells()
.should("have.length", ROWS_LIMIT * 2)
.eq(lastCellIndex)
.trigger("mouseup");
});
// Copy to clipboard
cy.realPress(["Meta", "c"]);
// Verify clipboard content has rows in sorted order
H.readClipboard().then((clipboardText) => {
// The clipboard should contain properly tab-separated content
// with newlines between rows (not a single cell)
const lines = clipboardText.split("\n");
// Should have header row + data rows (at least 6 lines: header + 5 data rows)
expect(lines.length).to.be.eq(ROWS_LIMIT + 1);
// Header should be tab-separated with both columns
const headerCells = lines[0].split("\t");
expect(headerCells).to.include("Title");
expect(headerCells).to.include("Category");
// Verify each data row is tab-separated and in the correct sorted order
const clipboardCategories = lines.slice(1).map((line) => {
const cells = line.split("\t");
// Category is the second column
return cells[1];
});
// The categories in clipboard should match the visual order
for (let i = 0; i < clipboardCategories.length; i++) {
expect(clipboardCategories[i]).to.equal(visualCategories[i]);
}
});
});
});
});
describe("issue 63416", () => {
const questionDetails = {
name: "63416 Question",

View File

@@ -75,7 +75,7 @@ describe("scenarios > dashboard > dashboard back navigation", () => {
cy.findByTestId("native-query-top-bar")
.findByText("This question is written in SQL.")
.should("be.visible");
H.NativeEditor.get().should("not.be.visible");
H.NativeEditor.get().should("not.exist");
});
it("should display a back to the dashboard button in table x-ray dashboards", () => {

View File

@@ -22,6 +22,7 @@ interface Table {
describe("bulk table operations", () => {
beforeEach(() => {
H.restore();
H.resetSnowplow();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
cy.intercept("POST", "/api/ee/data-studio/table/sync-schema").as(
@@ -63,6 +64,9 @@ describe("bulk table operations", () => {
cy.findByRole("heading", { name: /2 tables selected/ });
cy.findByRole("button", { name: /Sync settings/ }).click();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_bulk_sync_settings_clicked",
});
cy.findByRole("button", { name: /Sync table schemas/ }).click();
cy.findByRole("button", { name: /Sync triggered!/ }).should("be.visible");
cy.get<number[]>("@tableIds").then((tableIds) => {
@@ -73,6 +77,10 @@ describe("bulk table operations", () => {
},
);
});
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_schema_sync_started",
result: "success",
});
cy.findByRole("button", { name: /Re-scan tables/ }).click();
cy.findByRole("button", { name: /Scan triggered!/ }).should("be.visible");
@@ -85,6 +93,10 @@ describe("bulk table operations", () => {
},
);
});
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_fields_rescan_started",
result: "success",
});
cy.findByRole("button", { name: /Discard cached field values/ }).click();
cy.findByRole("button", { name: /Discard triggered!/ }).should(
@@ -98,6 +110,10 @@ describe("bulk table operations", () => {
expect(response?.statusCode).to.eq(204);
});
});
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_field_values_discard_started",
result: "success",
});
});
it(
@@ -134,6 +150,9 @@ describe("bulk table operations", () => {
cy.findByRole("button", { name: /Unpublish/ }).click();
H.modal().findByText("Unpublish these tables").click();
cy.wait("@unpublishTables");
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_unpublished",
});
H.DataStudio.nav().findByLabelText("Library").click();
H.DataStudio.Library.libraryPage().within(() => {
@@ -155,15 +174,35 @@ describe("bulk table operations", () => {
H.selectHasValue("Owner", "").click();
H.selectDropdown().contains("Bobby Tables").click();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_bulk_attribute_updated",
event_detail: "owner",
result: "success",
});
H.selectHasValue("Visibility type", "").click();
H.selectDropdown().contains("Gold").click();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_bulk_attribute_updated",
event_detail: "layer",
result: "success",
});
H.selectHasValue("Entity type", "").click();
H.selectDropdown().contains("Person").click();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_bulk_attribute_updated",
event_detail: "entity_type",
result: "success",
});
H.selectHasValue("Source", "").click();
H.selectDropdown().contains("Ingested").click();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_bulk_attribute_updated",
event_detail: "data_source",
result: "success",
});
H.undoToastList().should("have.length", 4);
TablePicker.getTable("Orders")
.findByTestId("table-owner")

View File

@@ -7,6 +7,7 @@ describe("Search", () => {
beforeEach(() => {
cy.signInAsAdmin();
H.restore("postgres-writable");
H.resetSnowplow();
H.activateToken("bleeding-edge");
H.resetTestTable({ type: "postgres", table: "multi_schema" });
H.resyncDatabase({ dbId: WRITABLE_DB_ID });
@@ -19,6 +20,9 @@ describe("Search", () => {
TablePicker.getTables().should("have.length", 3);
TablePicker.getTable("Analytic Events").should("be.visible");
TablePicker.getTable("Animals").should("be.visible");
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_picker_search_performed",
});
});
it("should support wildcard search with *", () => {
@@ -26,10 +30,19 @@ describe("Search", () => {
TablePicker.getSearchInput().type("irds");
TablePicker.get().findByText("No tables found").should("be.visible");
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_picker_search_performed",
});
TablePicker.getSearchInput().clear().type("*irds");
TablePicker.getTables().should("have.length", 1);
TablePicker.getTable("Birds").should("be.visible");
H.expectUnstructuredSnowplowEvent(
{
event: "data_studio_table_picker_search_performed",
},
2,
);
});
it("should allow using shift key to select multiple tables", () => {
@@ -105,6 +118,8 @@ describe("Search", () => {
it("should select/deselect databases and schemas", () => {
H.DataModel.visitDataStudio();
TablePicker.getSearchInput().type("a");
// wait for the tables to be loaded
TablePicker.getTables().should("have.length", 4);
const postgres = "Writable Postgres12";
const sampleDatabaseName = "Sample Database";
const domesticSchema = "Domestic";

View File

@@ -1,24 +1,23 @@
import {
SAMPLE_DB_ID,
SAMPLE_DB_SCHEMA_ID,
USER_GROUPS,
WRITABLE_DB_ID,
} from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
import { DataModel } from "e2e/support/helpers";
import {
NODATA_USER_ID,
ORDERS_QUESTION_ID,
} from "e2e/support/cypress_sample_instance_data";
import type { TableId } from "metabase-types/api";
const { H } = cy;
const { TablePicker, TableSection, FieldSection, PreviewSection } = H.DataModel;
const { ALL_USERS_GROUP } = USER_GROUPS;
const {
FEEDBACK,
FEEDBACK_ID,
ORDERS,
ORDERS_ID,
PEOPLE_ID,
PRODUCTS,
PRODUCTS_ID,
REVIEWS,
@@ -27,12 +26,10 @@ const {
const MYSQL_DB_ID = SAMPLE_DB_ID + 1;
const MYSQL_DB_SCHEMA_ID = `${MYSQL_DB_ID}:`;
const CUSTOM_MAPPING_ERROR =
"You need unrestricted data access on this table to map custom display values.";
describe("scenarios > data studio > datamodel", () => {
beforeEach(() => {
H.restore();
H.resetSnowplow();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
@@ -574,6 +571,12 @@ describe("scenarios > data studio > datamodel", () => {
openFilterPopover();
selectFilterOption("Visibility type", "Gold");
applyFilters();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_picker_filters_applied",
});
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_picker_search_performed",
});
cy.get<TableId>("@goldTableId").then(expectTableVisible);
cy.get<TableId>("@silverTableId").then(expectTableNotVisible);
@@ -1064,31 +1067,6 @@ describe("scenarios > data studio > datamodel", () => {
cy.findByText("New description").should("be.visible");
});
it("should allow changing the table name with data model permissions only", () => {
setDataModelPermissions({ tableIds: [ORDERS_ID] });
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
});
TableSection.getNameInput().clear().type("New orders").blur();
cy.wait("@updateTable");
verifyAndCloseToast("Table name updated");
TableSection.getNameInput().should("have.value", "New orders");
cy.signOut();
cy.signInAsNormalUser();
H.startNewQuestion();
H.miniPicker().within(() => {
cy.findByText("Sample Database").click();
cy.findByText("People").should("be.visible");
cy.findByText("New orders").should("be.visible");
});
});
it("should allow clearing the table description", () => {
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
@@ -1107,6 +1085,76 @@ describe("scenarios > data studio > datamodel", () => {
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("No description yet").should("be.visible");
});
it("should allow analysts to edit all table metadata even without data access", () => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
});
cy.log("change table name");
TableSection.getNameInput().clear().type("Analyst Orders").blur();
cy.wait("@updateTable");
verifyAndCloseToast("Table name updated");
TableSection.getNameInput().should("have.value", "Analyst Orders");
cy.log("change table description");
TableSection.getDescriptionInput()
.clear()
.type("Description by analyst")
.blur();
cy.wait("@updateTable");
verifyAndCloseToast("Table description updated");
TableSection.getDescriptionInput().should(
"have.value",
"Description by analyst",
);
cy.log("change field name");
TableSection.getFieldNameInput("Tax")
.clear()
.type("Analyst Tax")
.blur();
cy.wait("@updateField");
verifyAndCloseToast("Name of Tax updated");
TableSection.getFieldNameInput("Analyst Tax").should("be.visible");
cy.log("change field description");
TableSection.getFieldDescriptionInput("Total")
.clear()
.type("Total edited by analyst")
.blur();
cy.wait("@updateField");
verifyAndCloseToast("Description of Total updated");
cy.log("verify changes in data reference as admin");
cy.signInAsAdmin();
cy.visit(`/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}`);
cy.get("main").within(() => {
cy.findByText("Analyst Orders").should("be.visible");
cy.findByText("Description by analyst").should("be.visible");
});
cy.log("verify changes in question picker as normal user");
cy.signInAsNormalUser();
H.startNewQuestion();
H.miniPicker().within(() => {
cy.findByText("Sample Database").click();
cy.findByText("People").should("be.visible");
cy.findByText("Analyst Orders").should("be.visible");
});
cy.log("verify field changes in table visualization");
H.openOrdersTable();
H.tableHeaderColumn("Analyst Tax").should("be.visible");
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
"not.exist",
);
});
});
describe("Field name and description", () => {
@@ -1139,43 +1187,6 @@ describe("scenarios > data studio > datamodel", () => {
);
});
it("should allow changing the field name with data model permissions only", () => {
setDataModelPermissions({ tableIds: [ORDERS_ID] });
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
});
TableSection.getFieldNameInput("Tax").clear().type("New tax").blur();
cy.wait("@updateField");
verifyAndCloseToast("Name of Tax updated");
TableSection.getFieldNameInput("New tax").should("be.visible");
TableSection.getField("New tax").should("be.visible");
cy.log("verify preview");
TableSection.clickField("New tax");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify viz as normal user");
cy.signInAsNormalUser();
H.openOrdersTable();
H.tableHeaderColumn("New tax").should("be.visible");
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
"not.exist",
);
});
it("should allow changing the field description", () => {
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
@@ -1242,6 +1253,83 @@ describe("scenarios > data studio > datamodel", () => {
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("No description yet").should("be.visible");
});
it("should allow analysts to edit field metadata but not preview data without data access", () => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
});
cy.log("change field name from table section");
TableSection.getFieldNameInput("Tax")
.clear()
.type("Analyst Tax Field")
.blur();
cy.wait("@updateField");
verifyAndCloseToast("Name of Tax updated");
TableSection.getFieldNameInput("Analyst Tax Field").should(
"be.visible",
);
TableSection.getField("Analyst Tax Field").should("be.visible");
cy.log("change field description from table section");
TableSection.getFieldDescriptionInput("Total")
.clear()
.type("Analyst total description")
.blur();
cy.wait("@updateField");
verifyAndCloseToast("Description of Total updated");
TableSection.getFieldDescriptionInput("Total").should(
"have.value",
"Analyst total description",
);
cy.log("navigate to field detail and change semantic type");
TableSection.clickField("Discount");
FieldSection.getSemanticTypeInput()
.should("have.value", "Discount")
.click();
H.popover().findByText("Currency").click();
cy.wait("@updateField");
verifyAndCloseToast("Semantic type of Discount updated");
FieldSection.getSemanticTypeInput().should("have.value", "Currency");
cy.log("verify table preview is blocked without data permissions");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify detail preview is also blocked");
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify field changes in data reference as admin");
cy.signInAsAdmin();
cy.visit(
`/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/fields/${ORDERS.TOTAL}`,
);
cy.get("main").within(() => {
cy.findByText("Total").should("be.visible");
cy.findByText("Analyst total description").should("be.visible");
});
cy.log("verify field changes in table visualization as normal user");
cy.signInAsNormalUser();
H.openOrdersTable();
H.tableHeaderColumn("Analyst Tax Field").should("be.visible");
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
"not.exist",
);
H.tableHeaderColumn("Discount ($)").should("be.visible");
});
});
describe("Sorting", () => {
@@ -1534,46 +1622,6 @@ describe("scenarios > data studio > datamodel", () => {
);
});
it("should allow changing the field name with data model permissions only", () => {
setDataModelPermissions({ tableIds: [ORDERS_ID] });
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
fieldId: ORDERS.TOTAL,
});
FieldSection.getNameInput().clear().type("New total").blur();
cy.wait("@updateField");
verifyAndCloseToast("Name of Total updated");
FieldSection.getNameInput().should("have.value", "New total");
TableSection.getFieldNameInput("New total")
.scrollIntoView()
.should("be.visible");
cy.log("verify preview");
TableSection.clickField("New total");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify viz as normal user");
cy.signInAsNormalUser();
H.openOrdersTable();
H.tableHeaderColumn("New total").should("be.visible");
H.tableHeaderColumn("Total", { scrollIntoView: false }).should(
"not.exist",
);
});
it("should allow changing the field description", () => {
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
@@ -1981,84 +2029,6 @@ describe("scenarios > data studio > datamodel", () => {
cy.findByLabelText("Left column").should("contain.text", "User ID");
});
it("should allow to change the field foreign key target with no permissions to Reviews table", () => {
setDataModelPermissions({
tableIds: [ORDERS_ID, PRODUCTS_ID, PEOPLE_ID],
});
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
fieldId: ORDERS.USER_ID,
});
FieldSection.getSemanticTypeFkTarget()
.should("have.value", "People → ID")
.click();
H.popover().within(() => {
cy.findByText("Reviews → ID").should("not.exist");
cy.findByText("Products → ID").click();
});
cy.wait("@updateField");
H.undoToast().should(
"contain.text",
"Semantic type of User ID updated",
);
FieldSection.getSemanticTypeFkTarget().should(
"have.value",
"Products → ID",
);
cy.log("verify preview");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify viz as normal user");
cy.signInAsNormalUser();
H.openTable({
database: SAMPLE_DB_ID,
table: ORDERS_ID,
mode: "notebook",
});
cy.icon("join_left_outer").click();
H.miniPicker().within(() => {
cy.findByText("Sample Database").click();
cy.findByText("Products").click();
});
cy.findByLabelText("Left column").should("contain.text", "User ID");
});
it("should not allow setting foreign key target for inaccessible tables", () => {
setDataModelPermissions({ tableIds: [REVIEWS_ID] });
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.PRODUCT_ID,
});
FieldSection.getDisplayValuesInput().click();
H.popover().within(() => {
cy.findByRole("option", { name: /Use original value/ })
.should("be.visible")
.and("not.have.attr", "data-combobox-disabled");
cy.findByRole("option", { name: /Use foreign key/ })
.should("be.visible")
.and("have.attr", "data-combobox-disabled", "true");
});
});
it("should allow to change the type to 'Currency' and choose the currency (metabase#59052)", () => {
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
@@ -2238,6 +2208,56 @@ describe("scenarios > data studio > datamodel", () => {
},
);
});
it("should allow analysts to change the foreign key target without data access", () => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
fieldId: ORDERS.USER_ID,
});
FieldSection.getSemanticTypeFkTarget()
.should("have.value", "People → ID")
.click();
H.popover().within(() => {
cy.findByText("Reviews → ID").should("be.visible");
cy.findByText("Products → ID").click();
});
cy.wait("@updateField");
H.undoToast().should(
"contain.text",
"Semantic type of User ID updated",
);
FieldSection.getSemanticTypeFkTarget().should(
"have.value",
"Products → ID",
);
cy.log("verify preview is blocked without data permissions");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify FK target change works in query builder as normal user");
cy.signInAsNormalUser();
H.openTable({
database: SAMPLE_DB_ID,
table: ORDERS_ID,
mode: "notebook",
});
cy.icon("join_left_outer").click();
H.miniPicker().within(() => {
cy.findByText("Sample Database").click();
cy.findByText("Products").click();
});
cy.findByLabelText("Left column").should("contain.text", "User ID");
});
});
describe("Behavior", () => {
@@ -2653,34 +2673,6 @@ describe("scenarios > data studio > datamodel", () => {
.and("have.value", "Title");
});
it("should allow to change foreign key target for accessible tables", () => {
setDataModelPermissions({
tableIds: [ORDERS_ID, REVIEWS_ID, PRODUCTS_ID],
});
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.PRODUCT_ID,
});
FieldSection.getDisplayValuesInput().click();
H.popover().findByText("Use foreign key").click();
H.popover().findByText("Title").click();
cy.wait("@updateFieldDimension");
H.undoToast().should(
"contain.text",
"Display values of Product ID updated",
);
cy.signInAsNormalUser();
H.openReviewsTable({ limit: 1 });
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Rustic Paper Wallet").should("be.visible");
});
it("should allow 'Custom mapping' null values", () => {
const remappedNullValue = "nothin";
@@ -2868,54 +2860,6 @@ describe("scenarios > data studio > datamodel", () => {
});
});
it("should show a proper error message when using custom mapping", () => {
setDataModelPermissions({ tableIds: [REVIEWS_ID] });
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.RATING,
});
FieldSection.getDisplayValuesInput().click();
H.popover().within(() => {
cy.findByRole("option", { name: /Use original value/ })
.should("be.visible")
.and("not.have.attr", "data-combobox-disabled");
cy.findByRole("option", { name: /Custom mapping/ })
.should("be.visible")
.and("have.attr", "data-combobox-disabled", "true");
});
cy.signInAsAdmin();
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.RATING,
});
FieldSection.getDisplayValuesInput().click();
H.popover().findByText("Custom mapping").click();
cy.wait("@updateFieldDimension");
H.undoToast().should(
"contain.text",
"Display values of Rating updated",
);
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.RATING,
});
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText(CUSTOM_MAPPING_ERROR).should("exist");
});
it("should allow 'Custom mapping' option only for 'Search box' filtering type (metabase#16322)", () => {
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
@@ -3009,6 +2953,105 @@ describe("scenarios > data studio > datamodel", () => {
.eq(10) // 1st data row, 2nd column (User ID)
.should("have.text", "2023-10-07T01:34:35.462-07:00");
});
it("should allow analysts to change display values to use foreign key without data access", () => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.PRODUCT_ID,
});
FieldSection.getDisplayValuesInput().click();
H.popover().findByText("Use foreign key").click();
H.popover().findByText("Title").click();
cy.wait("@updateFieldDimension");
H.undoToast().should(
"contain.text",
"Display values of Product ID updated",
);
FieldSection.getDisplayValuesInput().should(
"have.value",
"Use foreign key",
);
FieldSection.getDisplayValuesFkTargetInput().should(
"have.value",
"Title",
);
cy.log("verify preview is blocked without data permissions");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify display value change works as normal user");
cy.signInAsNormalUser();
H.openReviewsTable({ limit: 1 });
H.main().findByText("Rustic Paper Wallet").should("be.visible");
});
it("should disable custom mapping for analysts without data access", () => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.RATING,
});
cy.log("verify custom mapping is disabled without data access");
FieldSection.getDisplayValuesInput().click();
H.popover().within(() => {
cy.findByRole("option", { name: /Use original value/ })
.should("be.visible")
.and("not.have.attr", "data-combobox-disabled");
cy.findByRole("option", { name: /Custom mapping/ })
.should("be.visible")
.and("have.attr", "data-combobox-disabled", "true");
});
cy.log("verify admin can set up custom mapping");
cy.signInAsAdmin();
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.RATING,
});
FieldSection.getDisplayValuesInput().click();
H.popover().findByText("Custom mapping").click();
cy.wait("@updateFieldValues");
H.undoToast().should(
"contain.text",
"Display values of Rating updated",
);
H.undoToast().icon("close").click({ force: true });
H.modal().within(() => {
cy.findByDisplayValue("1").click().clear().type("Terrible");
cy.findByDisplayValue("5").click().clear().type("Amazing");
cy.button("Save").click();
});
cy.wait("@updateFieldValues");
H.undoToast().should(
"contain.text",
"Display values of Rating updated",
);
cy.log("verify custom mapping works as normal user");
cy.signInAsNormalUser();
H.openReviewsTable();
H.main().findByText("Terrible").should("be.visible");
H.main().findAllByText("Amazing").should("be.visible");
});
});
describe("Unfold JSON", { tags: "@external" }, () => {
@@ -3480,7 +3523,7 @@ describe("scenarios > data studio > datamodel", () => {
fieldId: ORDERS.PRODUCT_ID,
});
DataModel.TableSection.getVisibilityTypeInput().click();
H.DataModel.TableSection.getVisibilityTypeInput().click();
H.popover().findByText("Copper").click();
cy.wait("@updateTable");
@@ -3868,27 +3911,6 @@ function clickAway() {
cy.get("body").click(0, 0);
}
const setDataModelPermissions = ({
tableIds = [],
}: {
tableIds: TableId[];
}) => {
const permissions = Object.fromEntries(tableIds.map((id) => [id, "all"]));
// @ts-expect-error invalid cy.updatePermissionsGraph typing
cy.updatePermissionsGraph({
[ALL_USERS_GROUP]: {
[SAMPLE_DB_ID]: {
"data-model": {
schemas: {
PUBLIC: permissions,
},
},
},
},
});
};
type TableSummary = {
id: TableId;
db_id: number;

View File

@@ -1,15 +1,11 @@
import {
SAMPLE_DB_ID,
SAMPLE_DB_SCHEMA_ID,
USER_GROUPS,
} from "e2e/support/cypress_data";
import { SAMPLE_DB_ID, SAMPLE_DB_SCHEMA_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { NODATA_USER_ID } from "e2e/support/cypress_sample_instance_data";
const { H } = cy;
const { SegmentList, SegmentEditor, SegmentRevisionHistory } = H.DataModel;
const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, PEOPLE, PEOPLE_ID } =
SAMPLE_DATABASE;
const { ALL_USERS_GROUP } = USER_GROUPS;
describe(
"scenarios > data studio > data model > segments",
@@ -614,12 +610,12 @@ describe(
});
});
describe("Readonly access with data model permissions", () => {
describe("Readonly access for data analysts", () => {
it("should show segments in list but hide New segment button for non-admin", () => {
createTestSegment({ name: "Readonly Test Segment" });
setDataModelPermissions({ tableIds: [ORDERS_ID] });
cy.signIn("none");
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
cy.log("verify segment is visible in list");
visitDataStudioSegments(ORDERS_ID);
@@ -636,9 +632,7 @@ describe(
cy.visit(
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/segments/new`,
);
H.main()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.url().should("include", "/unauthorized");
});
it("should display segment detail in readonly mode for non-admin", () => {
@@ -648,8 +642,8 @@ describe(
});
cy.get<number>("@segmentId").then((segmentId) => {
setDataModelPermissions({ tableIds: [ORDERS_ID] });
cy.signIn("none");
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
visitDataModelSegment(ORDERS_ID, segmentId);
@@ -766,20 +760,3 @@ function verifySegmentNotInQueryBuilder(
H.getNotebookStep("data").button("Filter").click();
H.popover().findByText(segmentName).should("not.exist");
}
function setDataModelPermissions({ tableIds = [] }: { tableIds: number[] }) {
const permissions = Object.fromEntries(tableIds.map((id) => [id, "all"]));
// @ts-expect-error invalid cy.updatePermissionsGraph typing
cy.updatePermissionsGraph({
[ALL_USERS_GROUP]: {
[SAMPLE_DB_ID]: {
"data-model": {
schemas: {
PUBLIC: permissions,
},
},
},
},
});
}

View File

@@ -22,14 +22,9 @@ describe("scenarios > data studio > library", () => {
H.DataModel.visitDataStudio();
H.DataStudio.nav().findByLabelText("Library").click();
cy.log("Closing the modal should send you back");
H.modal().button("Cancel").click();
H.DataModel.get().should("exist");
cy.log("Create library via modal");
H.DataStudio.nav().findByLabelText("Library").click();
H.modal().within(() => {
cy.findByText("Create your Library").should("be.visible");
cy.log("Create library via inline empty state");
H.DataStudio.Library.libraryPage().within(() => {
cy.findByText("A source of truth for analytics").should("be.visible");
cy.findByText("Create my Library").click();
});
@@ -111,7 +106,7 @@ describe("scenarios > data studio > library", () => {
cy.log("Publish a table from the 'New' menu");
H.DataStudio.Library.newButton().click();
H.popover().findByText("Publish a table").click();
H.popover().findByText("Published table").click();
cy.log("Select a table and click 'Publish'");
H.entityPickerModalItem(3, "Orders").click();
@@ -127,7 +122,7 @@ describe("scenarios > data studio > library", () => {
"Verify tables in the entity picker are disabled if already published",
);
H.DataStudio.Library.newButton().click();
H.popover().findByText("Publish a table").click();
H.popover().findByText("Published table").click();
H.entityPickerModalItem(3, "Orders").should("have.attr", "data-disabled");
H.entityPickerModalItem(3, "People").should(
"not.have.attr",
@@ -198,7 +193,7 @@ describe("scenarios > data studio > library", () => {
cy.log("Publish a table via the +New menu");
H.DataStudio.Library.newButton().click();
H.popover().findByText("Publish a table").click();
H.popover().findByText("Published table").click();
H.entityPickerModalItem(3, "Orders").click();
H.entityPickerModal().button("Publish").click();

View File

@@ -1,5 +1,6 @@
import { SAMPLE_DB_ID, SAMPLE_DB_SCHEMA_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { NODATA_USER_ID } from "e2e/support/cypress_sample_instance_data";
const { H } = cy;
const { MeasureList, MeasureEditor, MeasureRevisionHistory } = H.DataModel;
@@ -389,6 +390,76 @@ describe("scenarios > data studio > data model > measures", () => {
.should("be.visible");
});
});
describe("Readonly access for data analysts", () => {
it("should show measures in list but hide New measure button for non-admin", () => {
createTestMeasure({ name: "Readonly Test Measure" });
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
cy.log("verify measure is visible in list");
visitDataStudioMeasures(ORDERS_ID);
MeasureList.getMeasure("Readonly Test Measure")
.scrollIntoView()
.should("be.visible");
cy.log("verify New measure button is not visible");
MeasureList.get()
.findByRole("link", { name: /New measure/i })
.should("not.exist");
cy.log("verify direct navigation to new measure page is blocked");
cy.visit(
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/measures/new`,
);
cy.url().should("include", "/unauthorized");
});
it("should display measure detail in readonly mode for non-admin", () => {
createTestMeasure({
name: "Readonly Detail Measure",
description: "Test description for readonly",
});
cy.get<number>("@measureId").then((measureId) => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
visitDataModelMeasure(ORDERS_ID, measureId);
cy.log("verify measure name input is disabled");
MeasureEditor.get()
.findByDisplayValue("Readonly Detail Measure")
.should("be.disabled");
cy.log("verify description is displayed as plain text");
MeasureEditor.get().findByText("Description").should("be.visible");
MeasureEditor.get()
.findByText("Test description for readonly")
.should("be.visible");
cy.log("verify Save button is not visible");
MeasureEditor.get()
.findByRole("button", { name: /Save/i })
.should("not.exist");
cy.log("verify Remove measure option is hidden in actions menu");
MeasureEditor.getActionsButton().click();
H.popover().findByText("Preview").should("be.visible");
H.popover().findByText("Remove measure").should("not.exist");
cy.realPress("Escape");
cy.log("verify revision history is still accessible");
MeasureEditor.getRevisionHistoryTab().click();
MeasureRevisionHistory.get().within(() => {
cy.findByText(/created this measure/i)
.scrollIntoView()
.should("be.visible");
});
});
});
});
});
function visitDataStudioTable(tableId: number) {

View File

@@ -19,15 +19,15 @@ describe("scenarios > data studio > snippets", () => {
H.DataStudio.Library.visit();
H.DataStudio.Library.newButton().click();
H.popover().findByText("New snippet").click();
H.popover().findByText("Snippet").click();
H.DataStudio.Snippets.newPage().should("be.visible");
H.DataStudio.Snippets.saveButton().should("be.disabled");
H.DataStudio.Snippets.editor.type("SELECT * FROM orders");
H.DataStudio.Snippets.saveButton().should("be.disabled");
H.DataStudio.Snippets.saveButton().should("be.enabled");
H.DataStudio.Snippets.nameInput().type("Test snippet");
H.DataStudio.Snippets.nameInput().clear().type("Test snippet");
H.DataStudio.Snippets.saveButton().should("be.enabled");
H.DataStudio.Snippets.descriptionInput().type(
@@ -121,6 +121,42 @@ describe("scenarios > data studio > snippets", () => {
H.DataStudio.Snippets.editPage().should("be.visible");
});
it("should preserve unsaved content changes when description or name is edited", () => {
cy.log("Navigate to a snippet and edit its content");
H.createSnippet({
name: "Test snippet",
content: "SELECT * FROM orders",
});
H.DataStudio.Library.visit();
H.DataStudio.Library.libraryPage().findByText("Test snippet").click();
H.DataStudio.Snippets.editor.type("1");
cy.log("Edit its name");
cy.findByPlaceholderText("Name").type("1").blur();
H.undoToast().findByText("Snippet name updated").should("be.visible");
H.undoToast().icon("close").click();
cy.log("Edit its description");
H.DataStudio.Snippets.descriptionInput().type("desc").blur();
H.undoToast()
.findByText("Snippet description updated")
.should("be.visible");
H.undoToast().icon("close").click();
cy.log("Verify unsaved changes are preserved");
H.DataStudio.Snippets.editor
.value()
.should("eq", "SELECT * FROM orders1");
cy.log(
"Verify Save button saves the content without reverting the name and description changes",
);
H.DataStudio.Snippets.saveButton().click();
H.undoToast().findByText("Snippet content updated").should("be.visible");
cy.findByPlaceholderText("Name").should("have.value", "Test snippet1");
H.DataStudio.Snippets.editPage().findByText("desc").should("be.visible");
});
});
describe("description", () => {
@@ -178,7 +214,7 @@ describe("scenarios > data studio > snippets", () => {
H.DataStudio.Library.visit();
H.DataStudio.Library.newButton().click();
H.popover().findByText("New snippet folder").click();
H.popover().findByText("Snippet folder").click();
H.modal().within(() => {
cy.findByLabelText("Give your folder a name").type("Test Folder");
@@ -194,9 +230,9 @@ describe("scenarios > data studio > snippets", () => {
.should("be.visible");
H.DataStudio.Library.newButton().click();
H.popover().findByText("New snippet").click();
H.popover().findByText("Snippet").click();
H.DataStudio.Snippets.nameInput().type("Folder snippet");
H.DataStudio.Snippets.nameInput().clear().type("Folder snippet");
H.DataStudio.Snippets.editor.type("SELECT 1");
H.DataStudio.Snippets.saveButton().click();

View File

@@ -7,6 +7,7 @@ import type {
CardType,
CollectionId,
PythonTransformTableAliases,
TransformId,
TransformSourceCheckpointStrategy,
TransformTagId,
} from "metabase-types/api";
@@ -42,6 +43,9 @@ describe("scenarios > admin > transforms", () => {
cy.intercept("POST", "/api/ee/transform-tag").as("createTag");
cy.intercept("PUT", "/api/ee/transform-tag/*").as("updateTag");
cy.intercept("DELETE", "/api/ee/transform-tag/*").as("deleteTag");
cy.intercept("POST", "/api/ee/dependencies/check_transform").as(
"checkTransformDependencies",
);
});
afterEach(() => {
@@ -747,6 +751,49 @@ LIMIT
});
});
describe("ownership", () => {
it("should be able to view and manage transform ownership", () => {
createMbqlTransform({ visitTransform: true });
H.DataStudio.Transforms.settingsTab().click();
cy.log("verify the ownership section is displayed");
getTransformsTargetContent().within(() => {
cy.findByText("Ownership").should("be.visible");
cy.findByText("Specify who is responsible for this transform.").should(
"be.visible",
);
cy.findByText("Owner").should("be.visible");
});
cy.log("change the owner to another user");
getTransformsTargetContent().within(() => {
cy.findByLabelText("Owner").click();
});
H.popover().findByText("Robert Tableton").click();
cy.wait("@updateTransform");
H.undoToast().findByText("Transform owner updated").should("be.visible");
H.undoToast().icon("close").click();
cy.log("set an external email as owner");
getTransformsTargetContent().within(() => {
cy.findByLabelText("Owner").click();
cy.findByLabelText("Owner").clear().type("external@example.com");
});
H.popover().findByText("external@example.com").click();
cy.wait("@updateTransform");
H.undoToast().findByText("Transform owner updated").should("be.visible");
H.undoToast().icon("close").click();
cy.log("clear the owner");
getTransformsTargetContent().within(() => {
cy.findByLabelText("Owner").click();
});
H.popover().findByText("No owner").click();
cy.wait("@updateTransform");
H.undoToast().findByText("Transform owner updated").should("be.visible");
});
});
describe("tags", () => {
it("should be able to add and remove tags", () => {
createMbqlTransform({ visitTransform: true });
@@ -954,7 +1001,7 @@ LIMIT
// Make a second change while first is still in progress
// Select any available checkpoint field
getFieldPicker().should("be.visible");
getFieldPicker().scrollIntoView().should("be.visible");
getFieldPicker().click();
// Click the first available option in the popover
@@ -989,7 +1036,7 @@ LIMIT
// The source strategy select should be visible
// (Currently only one option "checkpoint" is available, so select might not be shown)
// The checkpoint field select should be visible
getFieldPicker().should("be.visible");
getFieldPicker().scrollIntoView().should("be.visible");
cy.log("Select a checkpoint field");
getFieldPicker().click();
@@ -1505,6 +1552,109 @@ LIMIT
H.assertQueryBuilderRowCount(1);
});
it("should be possible to continue editing a transform after closing check dependencies modal (metabase#68272)", () => {
const transformTableName = "output_table";
const dependentCardName = "Question depending on transform";
cy.log("create MBQL transform with name and score columns");
H.getTableId({ name: SOURCE_TABLE, databaseId: WRITABLE_DB_ID }).then(
(sourceTableId) => {
H.getFieldId({ tableId: sourceTableId, name: "name" }).then(
(nameFieldId) => {
H.getFieldId({ tableId: sourceTableId, name: "score" }).then(
(scoreFieldId) => {
H.createTransform(
{
name: "MBQL transform with deps",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "query",
query: {
"source-table": sourceTableId,
fields: [
["field", nameFieldId, null],
["field", scoreFieldId, null],
],
},
},
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: transformTableName,
schema: TARGET_SCHEMA,
},
},
{ wrapId: true },
);
},
);
},
);
},
);
cy.get<TransformId>("@transformId").then((transformId) => {
cy.log("run the transform to create the output table");
cy.request("POST", `/api/ee/transform/${transformId}/run`);
H.waitForSucceededTransformRuns();
H.resyncDatabase({
dbId: WRITABLE_DB_ID,
tableName: transformTableName,
});
cy.log("create a question that depends on the score column");
H.getTableId({
databaseId: WRITABLE_DB_ID,
name: transformTableName,
}).then((tableId) => {
H.createQuestion({
name: dependentCardName,
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
filter: [
">",
["field", "score", { "base-type": "type/Integer" }],
10,
],
},
});
});
H.visitTransform(transformId);
cy.log("remove score column (breaking change)");
H.DataStudio.Transforms.editDefinition().click();
H.getNotebookStep("data").findByLabelText("Pick columns").click();
H.popover().findByLabelText("Score").click();
H.DataStudio.Transforms.saveChangesButton().click();
cy.wait("@checkTransformDependencies");
H.modal().within(() => {
cy.findByText(
"These changes will break some other things. Save anyway?",
).should("be.visible");
cy.findByText(dependentCardName).should("be.visible");
cy.log("cancel to continue editing");
cy.button("Cancel").click();
});
H.DataStudio.Transforms.editDefinition().should("not.exist");
H.DataStudio.Transforms.saveChangesButton()
.should("be.visible")
.and("be.enabled");
H.getNotebookStep("data").findByLabelText("Pick columns").click();
H.popover().findByLabelText("Name").should("be.disabled");
H.popover().findByLabelText("Score").should("not.be.checked");
});
});
it("should be able to update a Python query", { tags: ["@python"] }, () => {
setPythonRunnerSettings();
cy.log("create a new transform");
@@ -2134,6 +2284,97 @@ LIMIT
},
);
it(
"should navigate to the common library when clicking 'common' in an import statement",
{ tags: ["@python"] },
() => {
visitTransformListPage();
cy.button("Create a transform").click();
H.popover().findByText("Python script").click();
cy.get(".cm-clickable-token").should("be.visible").click();
H.modal().button("Discard changes").click();
cy.url().should("include", "/data-studio/transforms/library/common.py");
cy.findByTestId("python-library-header").should("be.visible");
},
);
it(
"should open the common library in a new tab when cmd-clicking 'common' in an import statement",
{ tags: ["@python"] },
() => {
visitTransformListPage();
cy.window().then((win) => {
cy.stub(win, "open").as("windowOpen");
});
cy.button("Create a transform").click();
H.popover().findByText("Python script").click();
cy.get(".cm-clickable-token")
.should("be.visible")
.click({ metaKey: true });
cy.get("@windowOpen").should(
"have.been.calledWithMatch",
"/data-studio/transforms/library/common.py",
);
},
);
it(
"should be able to run a transform with default import common even without custom library code",
{ tags: ["@python"] },
() => {
setPythonRunnerSettings();
visitTransformListPage();
cy.button("Create a transform").click();
H.popover().findByText("Python script").click();
cy.log("import common should be included by default");
H.PythonEditor.value().should("contain", "import common");
cy.log(
"write a transform that imports common but does not use it - should still run",
);
H.PythonEditor.clear().type(
dedent`
import common
import pandas as pd
def transform():
return pd.DataFrame([{"result": 42}])
`,
{ allowFastSet: true },
);
cy.findByTestId("python-data-picker")
.findByText("Select a table…")
.click();
H.entityPickerModal().within(() => {
cy.findByText("Schema a").click();
cy.findByText("Animals").click();
});
getQueryEditor().button("Save").click();
H.modal().within(() => {
cy.findByLabelText("Name").clear().type("Default common transform");
cy.findByLabelText("Table name").clear().type("default_common");
cy.button("Save").click();
});
H.DataStudio.Transforms.runTab().click();
runTransformAndWaitForSuccess();
H.DataStudio.Transforms.settingsTab().click();
getTableLink().click();
H.queryBuilderHeader()
.findByText("Default Common")
.should("be.visible");
H.assertQueryBuilderRowCount(1);
cy.findByTestId("scalar-value").should("have.text", "42");
},
);
function visitCommonLibrary(path = "common.py") {
cy.visit(`/data-studio/transforms/library/${path}`);
}
@@ -2148,7 +2389,7 @@ LIMIT
cy.log("create a collection from the transforms list");
visitTransformListPage();
cy.button("Create a transform").click();
H.popover().findByText("New collection").click();
H.popover().findByText("Transform folder").click();
H.modal().within(() => {
cy.findByLabelText("Name").type("Marketing Transforms");
@@ -2161,7 +2402,7 @@ LIMIT
cy.log("create a nested collection");
cy.button("Create a transform").click();
H.popover().findByText("New collection").click();
H.popover().findByText("Transform folder").click();
H.modal().within(() => {
cy.findByLabelText("Name").type("Q4 Reports");
@@ -2588,6 +2829,85 @@ LIMIT
.should("be.visible");
});
});
describe("read-only remote sync", () => {
beforeEach(() => {
cy.log("create a transform");
createSqlTransform({
sourceQuery: `SELECT * FROM "${TARGET_SCHEMA}"."${SOURCE_TABLE}"`,
});
cy.log("set up remote sync");
H.setupGitSync();
H.configureGit("read-only");
});
it("should make the transform list page read-only", () => {
cy.log("visit transforms page");
visitTransformListPage();
cy.log("'Create a transform' menu button is not displayed");
cy.button("Create a transform").should("not.exist");
cy.log("clicking Python library navigates to the library editor");
getTransformsList().findByText("Python library").click();
cy.log("python library editor is read-only");
cy.url().should("include", "/data-studio/transforms/library/common.py");
cy.findByRole("alert")
.contains(/The Python library is not editable/)
.should("be.visible");
H.DataStudio.PythonLibrary.editor().within(() => {
cy.findByRole("textbox").should(
"have.attr",
"contenteditable",
"false",
);
cy.findByRole("textbox").should("have.attr", "aria-readonly", "true");
});
});
it("should not allow editing a transform", () => {
cy.log("visit transform");
cy.visit("/data-studio/transforms/1");
cy.log("'edit definition' button is not displayed");
H.DataStudio.Transforms.editDefinition().should("not.exist");
cy.log("visit the Run tab");
H.DataStudio.Transforms.runTab().click();
cy.log("schedule tags are not editable");
cy.findByLabelText("Tags").should("be.visible");
cy.findByLabelText("Tags").should("be.disabled");
cy.log("visit the Settings tab");
H.DataStudio.Transforms.settingsTab().click();
cy.log("'Change target' button is not displayed");
cy.findByRole("button", { name: /Change target/ }).should("not.exist");
cy.log("'Only process new and changed data' switch is not displayed");
cy.findByRole("switch", {
name: /Only process new and changed data/,
}).should("be.disabled");
cy.log("visiting edit mode url directly redirects to view-only mode");
cy.visit("/data-studio/transforms/1/edit");
cy.url().should("not.include", "/edit");
H.DataStudio.Transforms.header()
.findByRole("img", { name: "ellipsis icon" })
.click();
cy.log("ellipsis menu does not have move or delete options");
cy.findByRole("menu").within(() => {
cy.findByRole("menuitem", { name: /History/ }).should("be.visible");
cy.findByRole("menuitem", { name: /Move/ }).should("not.exist");
cy.findByRole("menuitem", { name: /Delete/ }).should("not.exist");
});
});
});
});
describe("scenarios > admin > transforms > databases without :schemas", () => {

View File

@@ -0,0 +1,542 @@
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { ADMIN_PERSONAL_COLLECTION_ID } from "e2e/support/cypress_sample_instance_data";
import type { TransformId } from "metabase-types/api";
const { H } = cy;
const TABLE_NAME = "test_transform_table";
const TABLE_DISPLAY_NAME = "Test Transform Table";
const TABLE_TRANSFORM = "Test Transform";
const TABLE_BASED_QUESTION_BROKEN_FIELD =
"Table-based question with broken field";
const TABLE_BASED_QUESTION_BROKEN_EXPRESSION =
"Table-based question with broken expression";
const TABLE_BASED_QUESTION_BROKEN_FILTER =
"Table-based question with broken filter";
const TABLE_BASED_QUESTION_BROKEN_BREAKOUT =
"Table-based question with broken breakout";
const TABLE_BASED_QUESTION_BROKEN_AGGREGATION =
"Table-based question with broken aggregation";
const TABLE_BASED_QUESTION_BROKEN_EXPLICIT_JOIN =
"Table-based question with broken explicit join";
const TABLE_BASED_QUESTION = "Table-based question";
const QUESTION_BASED_QUESTION_BROKEN_FILTER =
"Question-based question with broken filter";
const TABLE_BASED_MODEL = "Table-based model";
const MODEL_BASED_MODEL_BROKEN_AGGREGATION =
"Model-based model with broken aggregation";
const BROKEN_TABLE_DEPENDENCIES = [TABLE_DISPLAY_NAME];
const BROKEN_TABLE_DEPENDENTS = [
TABLE_BASED_QUESTION_BROKEN_FIELD,
TABLE_BASED_QUESTION_BROKEN_EXPRESSION,
TABLE_BASED_QUESTION_BROKEN_FILTER,
TABLE_BASED_QUESTION_BROKEN_BREAKOUT,
TABLE_BASED_QUESTION_BROKEN_AGGREGATION,
TABLE_BASED_QUESTION_BROKEN_EXPLICIT_JOIN,
];
const BROKEN_QUESTION_DEPENDENCIES = [TABLE_BASED_QUESTION];
const BROKEN_QUESTION_DEPENDENTS = [QUESTION_BASED_QUESTION_BROKEN_FILTER];
const BROKEN_MODEL_DEPENDENCIES = [TABLE_BASED_MODEL];
const BROKEN_MODEL_DEPENDENTS = [MODEL_BASED_MODEL_BROKEN_AGGREGATION];
const BROKEN_DEPENDENCIES = [
...BROKEN_TABLE_DEPENDENCIES,
...BROKEN_QUESTION_DEPENDENCIES,
...BROKEN_MODEL_DEPENDENCIES,
];
const BROKEN_DEPENDENCIES_SORTED_BY_NAME = [
TABLE_BASED_MODEL,
TABLE_BASED_QUESTION,
TABLE_DISPLAY_NAME,
];
const BROKEN_DEPENDENCIES_SORTED_BY_LOCATION = [
TABLE_BASED_QUESTION, // Bobby Tables's personal collection
TABLE_BASED_MODEL, // Our analytics
TABLE_DISPLAY_NAME, // Writable Postgres
];
const BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_ERRORS = [
TABLE_BASED_QUESTION, // 1 error: PRICE
TABLE_BASED_MODEL, // 1 error: AMOUNT
TABLE_DISPLAY_NAME, // 2 errors: SCORE, STATUS
];
const BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_WITH_ERRORS = [
TABLE_BASED_QUESTION, // 1 question
TABLE_BASED_MODEL, // 1 model
TABLE_DISPLAY_NAME, // 6 questions
];
const BROKEN_DEPENDENTS = [
...BROKEN_TABLE_DEPENDENTS,
...BROKEN_QUESTION_DEPENDENTS,
...BROKEN_MODEL_DEPENDENTS,
];
describe("scenarios > dependencies > broken list", () => {
beforeEach(() => {
H.restore("postgres-writable");
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
createContent();
});
afterEach(() => {
dropTransformTable();
});
describe("analysis", () => {
it("should show broken dependencies", () => {
H.DependencyDiagnostics.visitBrokenDependencies();
checkList({
visibleEntities: BROKEN_DEPENDENCIES,
hiddenEntities: BROKEN_DEPENDENTS,
});
});
});
describe("sidebar", () => {
it("should show broken dependents", () => {
H.DependencyDiagnostics.visitBrokenDependencies();
cy.log("table dependents");
H.DependencyDiagnostics.list().findByText(TABLE_DISPLAY_NAME).click();
checkSidebar({
entityName: TABLE_DISPLAY_NAME,
transformName: TABLE_TRANSFORM,
missingColumns: ["score", "status"],
brokenDependents: BROKEN_TABLE_DEPENDENTS,
});
cy.log("question dependents");
H.DependencyDiagnostics.list().findByText(TABLE_BASED_QUESTION).click();
checkSidebar({
entityName: TABLE_BASED_QUESTION,
missingColumns: ["PRICE"],
brokenDependents: BROKEN_QUESTION_DEPENDENTS,
});
cy.log("model dependents");
H.DependencyDiagnostics.list().findByText(TABLE_BASED_MODEL).click();
checkSidebar({
entityName: TABLE_BASED_MODEL,
missingColumns: ["AMOUNT"],
brokenDependents: BROKEN_MODEL_DEPENDENTS,
});
});
});
describe("search", () => {
it("should search for entities", () => {
H.DependencyDiagnostics.visitBrokenDependencies();
H.DependencyDiagnostics.searchInput().type(TABLE_DISPLAY_NAME);
checkList({
visibleEntities: [TABLE_DISPLAY_NAME],
hiddenEntities: [TABLE_BASED_QUESTION, TABLE_BASED_MODEL],
});
});
});
describe("filtering", () => {
it("should filter entities by type", () => {
H.DependencyDiagnostics.visitBrokenDependencies();
H.DependencyDiagnostics.filterButton().click();
H.popover().within(() => {
cy.findByText("Table").click();
cy.findByText("Question").click();
cy.findByText("Model").click();
});
cy.log("only tables");
H.popover().findByText("Table").click();
checkList({
visibleEntities: BROKEN_TABLE_DEPENDENCIES,
hiddenEntities: [
...BROKEN_QUESTION_DEPENDENCIES,
...BROKEN_MODEL_DEPENDENCIES,
],
});
cy.log("only questions");
H.popover().within(() => {
cy.findByText("Table").click();
cy.findByText("Question").click();
});
checkList({
visibleEntities: BROKEN_QUESTION_DEPENDENCIES,
hiddenEntities: [
...BROKEN_TABLE_DEPENDENCIES,
...BROKEN_MODEL_DEPENDENCIES,
],
});
cy.log("only models");
H.popover().within(() => {
cy.findByText("Question").click();
cy.findByText("Model").click();
});
checkList({
visibleEntities: BROKEN_MODEL_DEPENDENCIES,
hiddenEntities: [
...BROKEN_TABLE_DEPENDENCIES,
...BROKEN_QUESTION_DEPENDENCIES,
],
});
});
it("should filter entities by location", () => {
H.DependencyDiagnostics.visitBrokenDependencies();
H.DependencyDiagnostics.filterButton().click();
H.popover().within(() => {
cy.findByText("Include items in personal collections").click();
});
checkList({
visibleEntities: [
...BROKEN_TABLE_DEPENDENCIES,
...BROKEN_MODEL_DEPENDENCIES,
],
hiddenEntities: BROKEN_QUESTION_DEPENDENCIES,
});
});
});
describe("sorting", () => {
it("should sort by name", () => {
H.DependencyDiagnostics.visitBrokenDependencies();
cy.log("sorted by name by default");
checkListSorting({
visibleEntities: BROKEN_DEPENDENCIES_SORTED_BY_NAME,
});
cy.log("sorted by name ascending");
H.DependencyDiagnostics.list().findByText("Dependency").click();
checkListSorting({
visibleEntities: BROKEN_DEPENDENCIES_SORTED_BY_NAME,
});
cy.log("sorted by name descending");
H.DependencyDiagnostics.list().findByText("Dependency").click();
checkListSorting({
visibleEntities: [...BROKEN_DEPENDENCIES_SORTED_BY_NAME].reverse(),
});
});
it("should sort by location", () => {
H.DependencyDiagnostics.visitBrokenDependencies();
cy.log("sorted by location ascending");
H.DependencyDiagnostics.list().findByText("Location").click();
checkListSorting({
visibleEntities: BROKEN_DEPENDENCIES_SORTED_BY_LOCATION,
});
cy.log("sorted by location descending");
H.DependencyDiagnostics.list().findByText("Location").click();
checkListSorting({
visibleEntities: [...BROKEN_DEPENDENCIES_SORTED_BY_LOCATION].reverse(),
});
});
it("should sort by dependents errors", () => {
H.DependencyDiagnostics.visitBrokenDependencies();
cy.log("sorted by dependents errors ascending");
H.DependencyDiagnostics.list().findByText("Problems").click();
checkListSorting({
visibleEntities: [
...BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_ERRORS,
].reverse(),
});
cy.log("sorted by dependents errors descending");
H.DependencyDiagnostics.list().findByText("Problems").click();
checkListSorting({
visibleEntities: BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_ERRORS,
});
});
it("should sort by dependents with errors", () => {
H.DependencyDiagnostics.visitBrokenDependencies();
cy.log("sorted by dependents with errors ascending");
H.DependencyDiagnostics.list().findByText("Broken dependents").click();
checkListSorting({
visibleEntities: [
...BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_WITH_ERRORS,
].reverse(),
});
cy.log("sorted by dependents with errors descending");
H.DependencyDiagnostics.list().findByText("Broken dependents").click();
checkListSorting({
visibleEntities: BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_WITH_ERRORS,
});
});
});
});
function createContent() {
createTransform();
createTableContent();
createQuestionContent();
createModelContent();
breakTransform();
}
function dropTransformTable() {
cy.get<TransformId>("@transformId").then((transformId) => {
cy.request("DELETE", `/api/ee/transform/${transformId}/table`);
});
}
function createTransform() {
H.createTransform(
{
name: TABLE_TRANSFORM,
source: {
type: "query",
query: {
type: "native",
database: WRITABLE_DB_ID,
native: {
query: "SELECT 1 as score, 'active' as status",
},
},
},
target: {
type: "table",
database: WRITABLE_DB_ID,
schema: "public",
name: TABLE_NAME,
},
},
{ wrapId: true },
);
cy.get<TransformId>("@transformId").then((transformId) => {
H.runTransform(transformId);
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: TABLE_NAME });
H.waitForTransformRuns(
(runs) =>
runs.length === 1 && runs.every((run) => run.status === "succeeded"),
);
});
}
function breakTransform() {
cy.get<TransformId>("@transformId").then((transformId) => {
cy.request("PUT", `/api/ee/transform/${transformId}`, {
source: {
type: "query",
query: {
type: "native",
database: WRITABLE_DB_ID,
native: {
query: "SELECT 1 as score_new, 'active' as status_new",
},
},
},
});
H.runTransform(transformId);
H.waitForTransformRuns(
(runs) =>
runs.length === 2 && runs.every((run) => run.status === "succeeded"),
);
});
}
function createTableContent() {
H.getTableId({ name: TABLE_NAME }).then((tableId) => {
H.getFieldId({ tableId, name: "score" }).then((scoreFieldId) => {
H.getFieldId({ tableId, name: "status" }).then((statusFieldId) => {
H.createQuestion({
name: TABLE_BASED_QUESTION_BROKEN_FIELD,
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
fields: [["field", statusFieldId, null]],
},
});
H.createQuestion({
name: TABLE_BASED_QUESTION_BROKEN_EXPRESSION,
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
expressions: {
Expression: ["+", ["field", scoreFieldId, null], 0],
},
},
});
H.createQuestion({
name: TABLE_BASED_QUESTION_BROKEN_FILTER,
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
filter: ["=", ["field", scoreFieldId, null], 0],
},
});
H.createQuestion({
name: TABLE_BASED_QUESTION_BROKEN_BREAKOUT,
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
breakout: [["field", scoreFieldId, null]],
},
});
H.createQuestion({
name: TABLE_BASED_QUESTION_BROKEN_AGGREGATION,
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
aggregation: [["avg", ["field", scoreFieldId, null]]],
},
});
H.createQuestion({
name: TABLE_BASED_QUESTION_BROKEN_EXPLICIT_JOIN,
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
joins: [
{
"source-table": tableId,
alias: TABLE_DISPLAY_NAME,
condition: [
"=",
["field", statusFieldId, null],
[
"field",
statusFieldId,
{ "join-alias": TABLE_DISPLAY_NAME },
],
],
},
],
aggregation: [
[
"max",
["field", scoreFieldId, { "join-alias": TABLE_DISPLAY_NAME }],
],
],
},
});
});
});
});
}
function createQuestionContent() {
H.getTableId({ name: TABLE_NAME }).then((tableId) => {
H.createQuestion({
name: TABLE_BASED_QUESTION,
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
},
collection_id: ADMIN_PERSONAL_COLLECTION_ID,
}).then(({ body: card }) => {
H.createQuestion({
name: QUESTION_BASED_QUESTION_BROKEN_FILTER,
query: {
"source-table": `card__${card.id}`,
filter: [">", ["field", "PRICE", { "base-type": "type/Float" }], 10],
},
});
});
});
}
function createModelContent() {
H.getTableId({ name: TABLE_NAME }).then((tableId) => {
H.createQuestion({
name: TABLE_BASED_MODEL,
type: "model",
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
},
}).then(({ body: card }) => {
H.createQuestion({
name: MODEL_BASED_MODEL_BROKEN_AGGREGATION,
type: "model",
query: {
"source-table": `card__${card.id}`,
aggregation: [
["distinct", ["field", "AMOUNT", { "base-type": "type/Integer" }]],
],
},
});
});
});
}
function checkList({
visibleEntities = [],
hiddenEntities = [],
}: {
visibleEntities?: string[];
hiddenEntities?: string[];
}) {
H.DependencyDiagnostics.list().within(() => {
visibleEntities.forEach((name) => {
cy.findByText(name).should("exist");
});
hiddenEntities.forEach((name) => {
cy.findByText(name).should("not.exist");
});
});
}
function checkSidebar({
entityName,
transformName,
missingColumns,
brokenDependents,
}: {
entityName: string;
transformName?: string;
missingColumns?: string[];
brokenDependents?: string[];
}) {
H.DependencyDiagnostics.sidebar().within(() => {
H.DependencyDiagnostics.Sidebar.header()
.findByText(entityName)
.should("be.visible");
if (transformName) {
H.DependencyDiagnostics.Sidebar.transformSection()
.findByText(transformName)
.should("exist");
}
if (missingColumns) {
H.DependencyDiagnostics.Sidebar.missingColumnsSection().within(() => {
missingColumns.forEach((column) => {
cy.findByText(column).should("exist");
});
});
}
if (brokenDependents) {
H.DependencyDiagnostics.Sidebar.brokenDependentsSection().within(() => {
brokenDependents.forEach((dependent) => {
cy.findByText(dependent).should("exist");
});
});
}
});
}
function checkListSorting({ visibleEntities }: { visibleEntities: string[] }) {
H.DependencyDiagnostics.list().within(() => {
visibleEntities.forEach((name, index) => {
cy.findByText(name)
.parents("[data-index]")
.should("have.attr", "data-index", index.toString());
});
});
}

View File

@@ -193,10 +193,6 @@ describe("scenarios > dependencies > dependency checks", () => {
});
cy.get("@updateTransform.all").should("have.length", 0);
H.DataStudio.Transforms.editDefinition().click();
H.getNotebookStep("data").findByLabelText("Pick columns").click();
H.popover().findByLabelText("Score").click();
cy.log("confirm breaking changes");
H.DataStudio.Transforms.saveChangesButton().click();
H.modal().within(() => {

View File

@@ -611,25 +611,25 @@ describe("scenarios > dependencies > dependency graph", () => {
});
describe("dependent filtering", () => {
function verifyFilter({
filterName,
visibleItems,
hiddenItems,
}: {
filterName: string;
visibleItems: string[];
hiddenItems: string[];
}) {
function toggleFilter({ filterName }: { filterName: string }) {
H.DependencyGraph.dependencyPanel().icon("filter").click();
H.popover().findByText(filterName).click();
H.DependencyGraph.dependencyPanel().icon("filter").click();
}
function verifyItems({
visibleItems = [],
hiddenItems = [],
}: {
visibleItems?: string[];
hiddenItems?: string[];
}) {
H.DependencyGraph.dependencyPanel().within(() => {
visibleItems.forEach((item) =>
cy.findByText(item).should("be.visible"),
);
hiddenItems.forEach((item) => cy.findByText(item).should("not.exist"));
});
H.popover().findByText(filterName).click();
H.DependencyGraph.dependencyPanel().icon("filter").click();
}
it("should be able to filter questions", () => {
@@ -666,16 +666,20 @@ describe("scenarios > dependencies > dependency graph", () => {
.findByLabelText(TABLE_DISPLAY_NAME)
.findByText("5 questions")
.click();
verifyFilter({
filterName: "In a dashboard",
visibleItems: ["Question in dashboard"],
hiddenItems: [
verifyItems({
visibleItems: [
"Question in dashboard",
"Question in root collection",
"Question in first collection",
"Question in second collection",
"Question in personal collection",
],
});
verifyFilter({
filterName: "Not in personal collection",
toggleFilter({
filterName: "Include items in personal collections",
});
verifyItems({
visibleItems: [
"Question in dashboard",
"Question in root collection",

View File

@@ -1,5 +1,3 @@
const { H } = cy;
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
ADMIN_PERSONAL_COLLECTION_ID,
@@ -15,6 +13,7 @@ import type {
} from "metabase-types/api";
import { createMockParameter } from "metabase-types/api/mocks";
const { H } = cy;
const { ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE;
const MODEL_FOR_QUESTION_DATA_SOURCE = "Model for question data source";
@@ -105,8 +104,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
describe("analysis", () => {
it("should show unreferenced entities", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.list().within(() => {
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.list().within(() => {
ENTITY_NAMES.forEach((name) => {
cy.findByText(name).should("be.visible");
});
@@ -115,8 +114,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
it("should not show referenced entities", () => {
createEntities({ withReferences: true });
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.list().within(() => {
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.list().within(() => {
ENTITY_NAMES.forEach((name) => {
cy.findByText(name).should("not.exist");
});
@@ -127,8 +126,10 @@ describe("scenarios > dependencies > unreferenced list", () => {
describe("search", () => {
it("should search for entities", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.searchInput().type(MODEL_FOR_QUESTION_DATA_SOURCE);
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.searchInput().type(
MODEL_FOR_QUESTION_DATA_SOURCE,
);
checkList({
visibleEntities: [MODEL_FOR_QUESTION_DATA_SOURCE],
hiddenEntities: [MODEL_FOR_MODEL_DATA_SOURCE],
@@ -137,8 +138,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
it("should search for entities with type filters", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.searchInput().type("tag");
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.searchInput().type("tag");
checkList({
visibleEntities: [
MODEL_FOR_NATIVE_QUESTION_CARD_TAG,
@@ -147,7 +148,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
hiddenEntities: [MODEL_FOR_QUESTION_DATA_SOURCE],
});
H.DataStudio.Tasks.filterButton().click();
H.DependencyDiagnostics.filterButton().click();
H.popover().findByText("Snippet").click();
checkList({
visibleEntities: [MODEL_FOR_NATIVE_QUESTION_CARD_TAG],
@@ -159,10 +160,10 @@ describe("scenarios > dependencies > unreferenced list", () => {
describe("filters", () => {
it("should filter entities by type", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DependencyDiagnostics.visitUnreferencedEntities();
checkList({ visibleEntities: ENTITY_NAMES });
H.DataStudio.Tasks.filterButton().click();
H.DependencyDiagnostics.filterButton().click();
H.popover().findByText("Model").click();
checkList({ hiddenEntities: [MODEL_FOR_NATIVE_QUESTION_CARD_TAG] });
@@ -189,7 +190,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
it("should filter by location", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DependencyDiagnostics.visitUnreferencedEntities();
checkList({
visibleEntities: [
MODEL_FOR_MODEL_DATA_SOURCE,
@@ -198,7 +199,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
],
});
H.DataStudio.Tasks.filterButton().click();
H.DependencyDiagnostics.filterButton().click();
H.popover().findByText("Include items in personal collections").click();
checkList({
visibleEntities: [
@@ -222,8 +223,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
describe("sorting", () => {
it("should sort by name", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.searchInput().type("Model for");
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.searchInput().type("Model for");
cy.log("sorted by name by default");
checkListSorting({
@@ -231,13 +232,13 @@ describe("scenarios > dependencies > unreferenced list", () => {
});
cy.log("sorted by name ascending");
H.DataStudio.Tasks.list().findByText("Name").click();
H.DependencyDiagnostics.list().findByText("Name").click();
checkListSorting({
visibleEntities: MODELS_SORTED_BY_NAME,
});
cy.log("sorted by name descending");
H.DataStudio.Tasks.list().findByText("Name").click();
H.DependencyDiagnostics.list().findByText("Name").click();
checkListSorting({
visibleEntities: [...MODELS_SORTED_BY_NAME].reverse(),
});
@@ -245,17 +246,17 @@ describe("scenarios > dependencies > unreferenced list", () => {
it("should sort by location", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.searchInput().type("Model for");
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.searchInput().type("Model for");
cy.log("sorted by location ascending");
H.DataStudio.Tasks.list().findByText("Location").click();
H.DependencyDiagnostics.list().findByText("Location").click();
checkListSorting({
visibleEntities: MODELS_SORTED_BY_LOCATION,
});
cy.log("sorted by location descending");
H.DataStudio.Tasks.list().findByText("Location").click();
H.DependencyDiagnostics.list().findByText("Location").click();
checkListSorting({
visibleEntities: [...MODELS_SORTED_BY_LOCATION].reverse(),
});
@@ -265,9 +266,9 @@ describe("scenarios > dependencies > unreferenced list", () => {
describe("sidebar", () => {
it("should show the sidebar for supported entities", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DataStudio.Tasks.list()
H.DependencyDiagnostics.list()
.findByText(MODEL_FOR_QUESTION_DATA_SOURCE)
.click();
checkSidebar({
@@ -276,21 +277,25 @@ describe("scenarios > dependencies > unreferenced list", () => {
creatorName: "Bobby Tables",
});
H.DataStudio.Tasks.list().findByText(MODEL_FOR_MODEL_DATA_SOURCE).click();
H.DependencyDiagnostics.list()
.findByText(MODEL_FOR_MODEL_DATA_SOURCE)
.click();
checkSidebar({
entityName: MODEL_FOR_MODEL_DATA_SOURCE,
locationName: "First collection",
creatorName: "Bobby Tables",
});
H.DataStudio.Tasks.list().findByText(SEGMENT_FOR_QUESTION_FILTER).click();
H.DependencyDiagnostics.list()
.findByText(SEGMENT_FOR_QUESTION_FILTER)
.click();
checkSidebar({
entityName: SEGMENT_FOR_QUESTION_FILTER,
locationName: "Orders",
creatorName: "Bobby Tables",
});
H.DataStudio.Tasks.list()
H.DependencyDiagnostics.list()
.findByText(METRIC_FOR_QUESTION_AGGREGATION)
.click();
checkSidebar({
@@ -299,7 +304,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
creatorName: "Bobby Tables",
});
H.DataStudio.Tasks.list()
H.DependencyDiagnostics.list()
.findByText(METRIC_FOR_MODEL_AGGREGATION)
.click();
checkSidebar({
@@ -308,7 +313,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
creatorName: "Bobby Tables",
});
H.DataStudio.Tasks.list()
H.DependencyDiagnostics.list()
.findByText(SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG)
.click();
checkSidebar({
@@ -987,7 +992,7 @@ function checkList({
visibleEntities?: string[];
hiddenEntities?: string[];
}) {
H.DataStudio.Tasks.list().within(() => {
H.DependencyDiagnostics.list().within(() => {
visibleEntities.forEach((name) => {
cy.findByText(name).should("be.visible");
});
@@ -998,7 +1003,7 @@ function checkList({
}
function checkListSorting({ visibleEntities }: { visibleEntities: string[] }) {
H.DataStudio.Tasks.list().within(() => {
H.DependencyDiagnostics.list().within(() => {
visibleEntities.forEach((name, index) => {
cy.findByText(name)
.parents("[data-index]")
@@ -1016,17 +1021,17 @@ function checkSidebar({
locationName?: string;
creatorName?: string;
}) {
H.DataStudio.Tasks.sidebar().within(() => {
H.DataStudio.Tasks.Sidebar.header()
H.DependencyDiagnostics.sidebar().within(() => {
H.DependencyDiagnostics.Sidebar.header()
.findByText(entityName)
.should("be.visible");
if (locationName) {
H.DataStudio.Tasks.Sidebar.locationInfo()
H.DependencyDiagnostics.Sidebar.locationSection()
.findByText(locationName)
.should("be.visible");
}
if (creatorName) {
H.DataStudio.Tasks.Sidebar.creationInfo().should(
H.DependencyDiagnostics.Sidebar.creationSection().should(
"contain.text",
creatorName,
);

View File

@@ -1236,6 +1236,155 @@ describe("documents", () => {
});
});
describe("anchor links", () => {
// Helper to create filler paragraphs for scroll tests
const createFillerParagraphs = (count: number, startIndex: number) =>
Array.from({ length: count }, (_, i) => ({
type: "paragraph",
attrs: { _id: `filler-paragraph-${startIndex + i}` },
content: [
{
type: "text",
text: `This is filler paragraph ${startIndex + i} to make the document long enough to require scrolling. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
},
],
}));
beforeEach(() => {
H.createDocument({
name: "Anchor Test Document",
document: {
content: [
{
type: "heading",
attrs: { level: 1, _id: "heading-block-1" },
content: [{ type: "text", text: "First Heading" }],
},
{
type: "paragraph",
attrs: { _id: "paragraph-block-1" },
content: [{ type: "text", text: "Some content here" }],
},
// Add filler content to ensure scrolling is needed
...createFillerParagraphs(15, 1),
{
type: "heading",
attrs: { level: 2, _id: "heading-block-2" },
content: [{ type: "text", text: "Second Heading" }],
},
{
type: "paragraph",
attrs: { _id: "paragraph-block-2" },
content: [{ type: "text", text: "More content here" }],
},
// More filler to push blockquote down
...createFillerParagraphs(10, 16),
{
type: "blockquote",
attrs: { _id: "blockquote-block-1" },
content: [
{
type: "paragraph",
attrs: { _id: "quote-paragraph" },
content: [{ type: "text", text: "A nice quote" }],
},
],
},
],
type: "doc",
},
collection_id: null,
alias: "document",
idAlias: "documentId",
});
});
it("should show anchor link icon on left side when hovering over a heading", () => {
H.visitDocument("@documentId");
H.documentContent()
.findByRole("heading", { name: "First Heading" })
.realHover();
// Filter to visible one since all blocks have hidden buttons
cy.get('[data-testid="anchor-link-menu"]')
.filter(":visible")
.first()
.findByRole("button", { name: /copy link/i })
.should("be.visible");
});
it("should copy anchor URL to clipboard when clicking anchor link", () => {
H.visitDocument("@documentId");
cy.wrap(
Cypress.automation("remote:debugger:protocol", {
command: "Browser.grantPermissions",
params: {
permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"],
origin: window.location.origin,
},
}),
);
H.documentContent()
.findByRole("heading", { name: "First Heading" })
.realHover();
// Filter to visible one since all blocks have hidden buttons
cy.get('[data-testid="anchor-link-menu"]')
.filter(":visible")
.first()
.findByRole("button", { name: /copy link/i })
.click();
cy.get("body").findByText("Copied!").should("be.visible");
cy.window().then((win) => {
win.navigator.clipboard.readText().then((text) => {
expect(text).to.include("/document/");
expect(text).to.include("#heading-block-1");
});
});
});
it("should scroll to the correct block when navigating with anchor hash", () => {
cy.get("@documentId").then((documentId) => {
cy.visit(`/document/${documentId}#heading-block-2`);
H.documentContent()
.findByRole("heading", { name: "Second Heading" })
.should("be.visible");
H.documentContent()
.findByRole("heading", { name: "First Heading" })
.should("not.be.visible");
});
});
it("should still show comments menu on right side (regression check)", () => {
H.visitDocument("@documentId");
H.documentContent()
.findByRole("heading", { name: "First Heading" })
.realHover();
// Filter to visible one since all blocks have hidden menus
cy.get('[data-testid="anchor-link-menu"]')
.filter(":visible")
.first()
.findByRole("button", { name: /copy link/i })
.should("be.visible");
// Comments button uses ForwardRefLink, so it's a link role not button
cy.get('[data-testid="comments-menu"]')
.filter(":visible")
.first()
.findByRole("link", { name: /comments/i })
.should("be.visible");
});
});
describe("error handling", () => {
it("should display an error toast when creating a new document fails", () => {
// setup

View File

@@ -54,5 +54,15 @@ describe(
);
});
});
it("should not show CORS setting", () => {
cy.visit("/admin/embedding");
cy.findByTestId("admin-layout-content").within(() => {
cy.findByTestId("embedding-app-origins-sdk-setting").should(
"not.exist",
);
});
});
},
);

View File

@@ -46,4 +46,12 @@ describe("scenarios > embedding > admin settings > starter", () => {
cy.icon("gem").should("be.visible");
});
});
it("should not show CORS setting", () => {
cy.visit("/admin/embedding");
cy.findByTestId("admin-layout-content").within(() => {
cy.findByTestId("embedding-app-origins-sdk-setting").should("not.exist");
});
});
});

View File

@@ -1,5 +1,7 @@
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
import { embedModalEnableEmbedding } from "e2e/support/helpers";
import {
ORDERS_COUNT_QUESTION_ID,
ORDERS_QUESTION_ID,
} from "e2e/support/cypress_sample_instance_data";
import {
getEmbedSidebar,
@@ -19,7 +21,9 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
H.activateToken("bleeding-edge");
H.enableTracking();
H.updateSetting("enable-embedding-simple", true);
H.updateSetting("show-simple-embed-terms", false);
H.updateSetting("enable-embedding-static", true);
H.updateSetting("show-static-embed-terms", false);
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
@@ -77,8 +81,6 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
cy.wait("@dashboard");
H.embedModalEnableEmbedding();
cy.get("[data-iframe-loaded]", { timeout: 20000 }).should("have.length", 1);
H.modal().should("exist");
@@ -92,6 +94,26 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
});
describe("auth type switch", () => {
it("allows to select the `guest` item even when static embedding setting is disabled", () => {
H.updateSetting("enable-embedding-static", false);
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
visitNewEmbedPage({ waitForResource: false });
cy.findByLabelText("Guest").should("be.enabled");
});
it("allows to select the `Metabase Account` item even when simple embedding setting is disabled", () => {
H.updateSetting("enable-embedding-simple", false);
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
visitNewEmbedPage({ waitForResource: false });
cy.findByLabelText("Metabase account (SSO)").should("be.enabled");
});
it("should reset experience to a default only when switching from SSO to Guest auth type and the current experience does not support guest embeds", () => {
visitNewEmbedPage();
@@ -124,8 +146,6 @@ describe("scenarios > embedding > sdk iframe embed setup > common", () => {
H.openSharingMenu("Embed");
getEmbedSidebar().within(() => {
embedModalEnableEmbedding();
H.waitForSimpleEmbedIframesToLoad();
H.getSimpleEmbedIframeContent().within(() => {

View File

@@ -0,0 +1,48 @@
import { ORDERS_COUNT_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
import { visitNewEmbedPage } from "./helpers";
const { H } = cy;
describe("scenarios > embedding > sdk iframe embed setup > common (oss and starter)", () => {
describe("OSS", { tags: "@OSS" }, () => runTests({ token: null }));
describe("Starter", () => runTests({ token: "starter" }));
function runTests({ token }: { token: "starter" | null }) {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
if (token) {
H.activateToken(token);
}
H.enableTracking();
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
H.mockEmbedJsToDevServer();
});
it("allows to select the `guest` item even when static embedding setting is disabled", () => {
H.updateSetting("enable-embedding-static", false);
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
visitNewEmbedPage({ waitForResource: false });
cy.findByLabelText("Guest").should("be.enabled");
});
it("does not allow to select the `Metabase Account`, when token feature is missing (oss)", () => {
H.updateSetting("enable-embedding-simple", false);
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
visitNewEmbedPage({ waitForResource: false });
cy.findByLabelText("Metabase account (SSO)").should("be.disabled");
});
}
});

View File

@@ -4,19 +4,21 @@ import { getEmbedSidebar } from "./helpers";
const { H } = cy;
const { IS_ENTERPRISE } = Cypress.env();
const IS_OSS = !IS_ENTERPRISE;
const MB_EDITION = IS_ENTERPRISE ? "ee" : "oss";
const DATA_BY_MB_EDITION = {
oss: {
const DATA_BY_EMBEDDING_TYPE = {
guest: {
path: "/admin/embedding/guest",
token: null,
authMethodLabel: "Guest",
cardTestId: "guest-embeds-setting-card",
cardText:
"To continue, enable guest embeds and agree to the usage conditions.",
embeddingSettingName: "enable-embedding-static",
showTermsSettingName: "show-static-embed-terms",
},
ee: {
modular: {
path: "/admin/embedding",
token: "bleeding-edge",
authMethodLabel: "Metabase account (SSO)",
cardTestId: "sdk-setting-card",
cardText:
"To continue, enable modular embedding and agree to the usage conditions.",
@@ -25,19 +27,24 @@ const DATA_BY_MB_EDITION = {
},
} as const;
describe(
`scenarios > embedding > sdk iframe embed setup > enable embed js > ${MB_EDITION}`,
{ ...(IS_OSS && { tags: "@OSS" }) },
() => {
const { embeddingSettingName, showTermsSettingName, cardTestId, cardText } =
DATA_BY_MB_EDITION[MB_EDITION];
Object.entries(DATA_BY_EMBEDDING_TYPE).forEach(([key, value]) => {
describe(`scenarios > embedding > sdk iframe embed setup > enable embed js (EE) > ${key}`, () => {
const {
path,
token,
authMethodLabel,
embeddingSettingName,
showTermsSettingName,
cardTestId,
cardText,
} = value;
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
if (IS_ENTERPRISE) {
H.activateToken("bleeding-edge");
if (token) {
H.activateToken(token);
}
H.mockEmbedJsToDevServer();
@@ -47,7 +54,7 @@ describe(
H.updateSetting(embeddingSettingName, false);
H.updateSetting(showTermsSettingName, true);
cy.visit("/admin/embedding");
cy.visit(path);
cy.findAllByTestId(cardTestId)
.first()
@@ -55,6 +62,8 @@ describe(
cy.findByText("New embed").click();
});
cy.findByLabelText(authMethodLabel).click();
embedModalEnableEmbeddingCard().within(() => {
cy.findByText(cardText).should("exist");
});
@@ -111,7 +120,7 @@ describe(
H.updateSetting(embeddingSettingName, true);
H.updateSetting(showTermsSettingName, true);
cy.visit("/admin/embedding");
cy.visit(path);
cy.findAllByTestId(cardTestId)
.first()
@@ -119,6 +128,8 @@ describe(
cy.findByText("New embed").click();
});
cy.findByLabelText(authMethodLabel).click();
embedModalEnableEmbeddingCard().within(() => {
cy.findByText("Agree to the usage conditions to continue.").should(
"exist",
@@ -141,7 +152,7 @@ describe(
H.updateSetting(embeddingSettingName, true);
H.updateSetting(showTermsSettingName, false);
cy.visit("/admin/embedding");
cy.visit(path);
cy.findAllByTestId(cardTestId)
.first()
@@ -149,7 +160,9 @@ describe(
cy.findByText("New embed").click();
});
cy.findByLabelText(authMethodLabel).click();
getEmbedSidebar().contains(cardText).should("not.exist");
});
},
);
});
});

View File

@@ -0,0 +1,168 @@
import { embedModalEnableEmbeddingCard } from "e2e/support/helpers";
import { getEmbedSidebar } from "./helpers";
const { H } = cy;
describe("scenarios > embedding > sdk iframe embed setup > enable embed js (oss and starter)", () => {
describe("OSS", { tags: "@OSS" }, () => runTests({ token: null }));
describe("Starter", () => runTests({ token: "starter" }));
function runTests({ token }: { token: "starter" | null }) {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
if (token) {
H.activateToken(token);
}
H.mockEmbedJsToDevServer();
});
describe("guest", () => {
it("shows the Enable to Continue button and enables embedding on click", () => {
H.updateSetting("enable-embedding-static", false);
H.updateSetting("show-static-embed-terms", true);
cy.visit("/admin/embedding");
cy.findAllByTestId("guest-embeds-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
embedModalEnableEmbeddingCard().within(() => {
cy.findByText(
"To continue, enable guest embeds and agree to the usage conditions.",
).should("exist");
});
cy.log("shows tooltip with fair usage info");
embedModalEnableEmbeddingCard()
.findByLabelText("info icon")
.trigger("mouseover");
H.hovercard()
.contains(
/You should, however, read the license text linked above as that is the actual license that you will be agreeing to by enabling this feature/,
)
.should("be.visible");
embedModalEnableEmbeddingCard()
.findByLabelText("info icon")
.trigger("mouseout");
cy.findByRole("button", { name: "Agree and enable" }).should(
"be.visible",
);
cy.log("preview panel should show placeholder");
cy.get('[alt="No results"]').should("be.visible");
cy.findByRole("button", { name: "Agree and enable" }).click();
cy.log("button should change to Enabled state");
cy.findByRole("button", { name: /Enabled/ })
.should("be.visible")
.should("be.disabled");
// Going to the next step and selecting "Orders in a dashboard" explicitely
// because sometimes it selects another one that's been used recently
// see EMB-1106
cy.log(
"Navigating to embed flow step 2 and selecting an item to embed",
);
cy.findByRole("button", { name: "Next" }).click();
cy.get('[data-testid="embed-recent-item-card"]')
.should("have.length.greaterThan", 0)
.contains("Orders in a dashboard")
.click();
cy.log("Preview should load after embedding is enabled");
H.waitForSimpleEmbedIframesToLoad();
H.getSimpleEmbedIframeContent().within(() => {
cy.findByText("Orders in a dashboard", { timeout: 60_000 }).should(
"be.visible",
);
});
});
it("shows the enable card with fair usage terms when embedding is already enabled", () => {
H.updateSetting("enable-embedding-static", true);
H.updateSetting("show-static-embed-terms", true);
cy.visit("/admin/embedding");
cy.findAllByTestId("guest-embeds-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
embedModalEnableEmbeddingCard().within(() => {
cy.findByText("Agree to the usage conditions to continue.").should(
"exist",
);
cy.findByText(
"To continue, enable guest embeds and agree to the usage conditions.",
).should("not.exist");
});
cy.log("shows tooltip with fair usage info");
getEmbedSidebar().findByLabelText("info icon").trigger("mouseover");
H.hovercard()
.contains(
/You should, however, read the license text linked above as that is the actual license that you will be agreeing to by enabling this feature/,
)
.should("be.visible");
});
it("hides the enable card when embedding is already enabled", () => {
H.updateSetting("enable-embedding-static", true);
H.updateSetting("show-static-embed-terms", false);
cy.visit("/admin/embedding");
cy.findAllByTestId("guest-embeds-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
getEmbedSidebar()
.contains(
"To continue, enable guest embeds and agree to the usage conditions.",
)
.should("not.exist");
});
});
describe("Metabase account (sso)", () => {
it("does not show the Enable to Continue button and disables item", () => {
H.updateSetting("enable-embedding-simple", false);
H.updateSetting("show-simple-embed-terms", true);
cy.visit("/admin/embedding");
cy.findAllByTestId("guest-embeds-setting-card")
.first()
.within(() => {
cy.findByText("New embed").click();
});
embedModalEnableEmbeddingCard().within(() => {
cy.findByText(
"To continue, enable guest embeds and agree to the usage conditions.",
).should("not.exist");
});
cy.findByLabelText("Metabase account (SSO)").should("be.disabled");
});
});
}
});

View File

@@ -56,6 +56,7 @@ describe(suiteTitle, () => {
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: "Dashboard with Parameters",
preselectSso: true,
});
getEmbedSidebar().within(() => {
@@ -85,6 +86,7 @@ describe(suiteTitle, () => {
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: "Dashboard with Parameters",
preselectSso: true,
});
cy.log("set default value for id");
@@ -127,6 +129,7 @@ describe(suiteTitle, () => {
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: "Dashboard with Parameters",
preselectSso: true,
});
cy.log("hide both parameters");
@@ -179,6 +182,7 @@ describe(suiteTitle, () => {
navigateToEmbedOptionsStep({
experience: "chart",
resourceName: "Question with Parameters",
preselectSso: true,
});
getEmbedSidebar().within(() => {
@@ -223,6 +227,7 @@ describe(suiteTitle, () => {
navigateToEmbedOptionsStep({
experience: "dashboard",
resourceName: "Orders in a dashboard",
preselectSso: true,
});
getEmbedSidebar().within(() => {
@@ -236,6 +241,7 @@ describe(suiteTitle, () => {
navigateToEmbedOptionsStep({
experience: "chart",
resourceName: "Orders, Count",
preselectSso: true,
});
getEmbedSidebar().within(() => {
@@ -246,7 +252,10 @@ describe(suiteTitle, () => {
});
it("should not show parameter settings for exploration template", () => {
navigateToEntitySelectionStep({ experience: "exploration" });
navigateToEntitySelectionStep({
experience: "exploration",
preselectSso: true,
});
getEmbedSidebar().within(() => {
cy.log("go to embed options step");

View File

@@ -12,10 +12,7 @@ const { H } = cy;
const DASHBOARD_NAME = "Orders in a dashboard";
const QUESTION_NAME = "Orders, Count";
const suiteTitle =
"scenarios > embedding > sdk iframe embed setup > get code step";
describe(suiteTitle, () => {
describe("scenarios > embedding > sdk iframe embed setup > get code step", () => {
beforeEach(() => {
H.restore();
H.resetSnowplow();
@@ -160,6 +157,18 @@ describe(suiteTitle, () => {
});
});
it("should track embed_wizard_options_completed with settings=default properly (metabase#68285)", () => {
navigateToGetCodeStep({
experience: "chart",
resourceName: QUESTION_NAME,
});
H.expectUnstructuredSnowplowEvent({
event: "embed_wizard_options_completed",
event_detail: "settings=default",
});
});
it("should not include useExistingUserSession when SSO is selected", () => {
enableJwtAuth();

View File

@@ -1,6 +1,7 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
JWT_SHARED_SECRET,
embedModalEnableEmbedding,
entityPickerModal,
getParametersContainer,
} from "e2e/support/helpers";
@@ -376,8 +377,13 @@ describe("scenarios > embedding > sdk iframe embed setup > guest-embed", () => {
cy.findByText("Back").click();
cy.findByLabelText("Guest").should("be.visible").should("be.checked");
cy.findByLabelText("Metabase account (SSO)").click();
cy.findByLabelText("Metabase account (SSO)").click();
});
embedModalEnableEmbedding();
getEmbedSidebar().within(() => {
cy.findByText("Next").click();
cy.findByText("Next").click();
cy.findByText("Get code").click();

View File

@@ -36,15 +36,10 @@ export const visitNewEmbedPage = (
cy.findByText("New embed").click();
});
cy.get("body").then(($body) => {
const isEmbeddingDisabled =
$body.find('[data-testid="enable-embedding-card"]').length > 0;
if (isEmbeddingDisabled) {
embedModalEnableEmbedding();
}
cy.get("body").then(() => {
if (waitForResource) {
embedModalEnableEmbedding();
cy.wait("@dashboard");
cy.get("[data-iframe-loaded]", { timeout: 20000 }).should(
@@ -103,6 +98,7 @@ export const navigateToEntitySelectionStep = (
if (preselectSso || !isQuestionOrDashboardExperience) {
cy.findByLabelText("Metabase account (SSO)").click();
embedModalEnableEmbedding();
}
const labelByExperience = match(experience)

View File

@@ -3,7 +3,10 @@ import {
ORDERS_COUNT_QUESTION_ID,
ORDERS_DASHBOARD_ID,
} from "e2e/support/cypress_sample_instance_data";
import { mockEmbedJsToDevServer } from "e2e/support/helpers";
import {
embedModalEnableEmbedding,
mockEmbedJsToDevServer,
} from "e2e/support/helpers";
import {
getEmbedSidebar,
@@ -221,7 +224,11 @@ describe(suiteTitle, () => {
getEmbedSidebar().within(() => {
cy.findByLabelText("Metabase account (SSO)").click();
});
embedModalEnableEmbedding();
getEmbedSidebar().within(() => {
cy.findByText("Browser").click();
cy.findByText("Next").click();
cy.findByText("Select a collection to embed").should("be.visible");
@@ -304,7 +311,11 @@ describe(suiteTitle, () => {
it("can open a collection picker from browser empty state", () => {
getEmbedSidebar().within(() => {
cy.findByLabelText("Metabase account (SSO)").click();
});
embedModalEnableEmbedding();
getEmbedSidebar().within(() => {
cy.findByText("Browser").click();
cy.findByText("Next").click();

View File

@@ -2,6 +2,7 @@ import {
ORDERS_COUNT_QUESTION_ID,
ORDERS_QUESTION_ID,
} from "e2e/support/cypress_sample_instance_data";
import { embedModalEnableEmbedding } from "e2e/support/helpers";
import {
assertDashboard,
@@ -66,48 +67,6 @@ describe(suiteTitle, () => {
});
});
it("shows the `guest embeds disabled` icon + tooltip when guest embeds disabled", () => {
H.updateSetting("enable-embedding-static", false);
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
visitNewEmbedPage({ waitForResource: false });
cy.url().should("not.match", /\/admin\/embedding\/guest/);
getEmbedSidebar().within(() => {
cy.findByTestId("guest-embeds-disabled-info-icon").should("be.visible");
cy.findByTestId("guest-embeds-disabled-info-icon").click();
});
H.hovercard()
.findByText(/You can enable guest embeds in/)
.should("exist");
H.hovercard()
.findByText(/admin settings/)
.click();
cy.findAllByTestId(/(sdk-setting-card|guest-embeds-setting-card)/)
.first()
.within(() => {
cy.url().should("match", /\/admin\/embedding\/guest/);
cy.findByText("Enable guest embeds").should("be.visible");
});
});
it("hides the `guest embeds disabled` icon + tooltip when guest embeds enabled", () => {
H.updateSetting("enable-embedding-static", true);
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
visitNewEmbedPage({ waitForResource: false });
getEmbedSidebar().within(() => {
cy.findByTestId("guest-embeds-disabled-info-icon").should("not.exist");
});
});
it("shows the most recent question from the activity log when selected", () => {
const questionName = "Orders, Count";
@@ -165,7 +124,11 @@ describe(suiteTitle, () => {
getEmbedSidebar().within(() => {
cy.findByLabelText("Metabase account (SSO)").click();
});
embedModalEnableEmbedding();
getEmbedSidebar().within(() => {
cy.findByText("Browser").click();
cy.findByText("Next").click();
});
@@ -307,7 +270,11 @@ describe(suiteTitle, () => {
getEmbedSidebar().within(() => {
cy.findByLabelText("Metabase account (SSO)").click();
});
embedModalEnableEmbedding();
getEmbedSidebar().within(() => {
cy.findByText("Metabot").click();
cy.findByText("Next").click();
});

View File

@@ -1,5 +1,8 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { mockEmbedJsToDevServer } from "e2e/support/helpers";
import {
embedModalEnableEmbedding,
mockEmbedJsToDevServer,
} from "e2e/support/helpers";
import {
codeBlock,
@@ -208,7 +211,11 @@ describe(suiteTitle, () => {
cy.button("Back").click();
cy.findByLabelText("Metabase account (SSO)").click();
});
embedModalEnableEmbedding();
getEmbedSidebar().within(() => {
cy.button("Next").click();
cy.button("Next").click();

View File

@@ -166,7 +166,8 @@ describe("scenarios > embedding > sdk iframe embed options passthrough", () => {
);
cy.log("2. clicking on the column value should not show the popover");
cy.findAllByText("37.65").first().should("be.visible").click();
cy.findAllByText("37.65").first().should("be.visible");
cy.findAllByText("37.65").first().click();
cy.findByText(/Filter by this value/).should("not.exist");
});
});

View File

@@ -60,6 +60,13 @@ const assertEditorDiffState = (opts: { exists: boolean }) => {
.should(should);
};
const sendCodgenBotMessage = (message: string) => {
H.sendMetabotMessage(message);
cy.wait("@metabotAgent").its("request.body").should("deep.include", {
profile_id: "transforms_codegen",
});
};
const assertAcceptRejectUI = (opts: { visible: boolean }) => {
const should = opts.visible ? "be.visible" : "not.exist";
acceptSuggestionBtn().should(should);
@@ -96,7 +103,7 @@ describe(
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 1"),
),
});
H.sendMetabotMessage(
sendCodgenBotMessage(
"Create a new native SQL transform that gives me the number 1",
);
assertSuggestionInSidebar({ newSourcePartial: "SELECT 1" });
@@ -115,7 +122,7 @@ describe(
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 2"),
),
});
H.sendMetabotMessage("Make this give me the number 2 instead");
sendCodgenBotMessage("Make this give me the number 2 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 1",
newSourcePartial: "SELECT 2",
@@ -137,7 +144,7 @@ describe(
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 4"),
),
});
H.sendMetabotMessage("Make this give me the number 4 instead");
sendCodgenBotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 3",
newSourcePartial: "SELECT 4",
@@ -166,7 +173,7 @@ describe(
),
),
});
H.sendMetabotMessage(
sendCodgenBotMessage(
"Create a new native python transform that gives me the number 1",
);
assertSuggestionInSidebar({
@@ -192,7 +199,7 @@ describe(
),
),
});
H.sendMetabotMessage("Make this give me the number 2 instead");
sendCodgenBotMessage("Make this give me the number 2 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [1]})",
newSourcePartial: "pd.DataFrame({'value': [2]})",
@@ -226,7 +233,7 @@ describe(
),
),
});
H.sendMetabotMessage("Make this give me the number 4 instead");
sendCodgenBotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [3]})",
newSourcePartial: "pd.DataFrame({'value': [4]})",
@@ -274,7 +281,7 @@ describe(
),
),
});
H.sendMetabotMessage(
sendCodgenBotMessage(
"Create a transform that queries the Test Model",
);
assertSuggestionInSidebar({ newSourcePartial: "SELECT * FROM" });
@@ -319,7 +326,7 @@ describe(
),
});
});
H.sendMetabotMessage(
sendCodgenBotMessage(
"Update my SQL transform to select 2 instead of 1.",
);
assertSuggestionInSidebar({
@@ -352,7 +359,7 @@ describe(
),
});
});
H.sendMetabotMessage("Make this give me the number 4 instead");
sendCodgenBotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 3",
newSourcePartial: "SELECT 4",
@@ -396,7 +403,7 @@ describe(
),
});
});
H.sendMetabotMessage(
sendCodgenBotMessage(
"Update my SQL transform to select 2 instead of 1.",
);
assertSuggestionInSidebar({
@@ -438,7 +445,7 @@ describe(
),
),
});
H.sendMetabotMessage("Make this give me the number 4 instead");
sendCodgenBotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [3]})",
newSourcePartial: "pd.DataFrame({'value': [4]})",

View File

@@ -435,7 +435,7 @@ describe("issue 14302", () => {
expect(xhr.response.body.error).not.to.exist;
});
H.NativeEditor.get().should("not.be.visible");
H.NativeEditor.get().should("not.exist");
cy.get("[data-testid=cell-data]").should("contain", "51");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Showing 1 row");

View File

@@ -682,8 +682,7 @@ describe("issue 59110", () => {
H.NativeEditor.get().then((editor) => {
const { height } = editor[0].getBoundingClientRect();
const SLOPPY_CLICK_THRESHOLD = 30;
const diff = height - SLOPPY_CLICK_THRESHOLD;
const diff = height + 20;
cy.log("drag the border to hide the editor");
@@ -698,12 +697,11 @@ describe("issue 59110", () => {
.trigger("mousemove", {
clientX: coordsDrag.x,
clientY: coordsDrag.y - diff,
})
.trigger("mouseup");
});
});
});
H.NativeEditor.get().should("not.be.visible");
H.NativeEditor.get().should("not.exist");
cy.findByTestId("visibility-toggler")
.findByText(/open editor/i)
.should("be.visible")
@@ -762,7 +760,9 @@ describe("issue 60719", () => {
describe("issue 59356", () => {
function typeRunShortcut() {
cy.get("body").type("{ctrl+enter}{cmd+enter}");
const isMac = Cypress.platform === "darwin";
const metaKey = isMac ? "Meta" : "Control";
cy.realPress([metaKey, "Enter"]);
}
function getLoader() {
@@ -974,3 +974,43 @@ describe("issue 51717", () => {
cy.get("@infoSidebar").should("contain", "You created this");
});
});
describe("issue 59075", () => {
const WINDOW_HEIGHT = 1000;
const BUTTON_INDEX = 0;
beforeEach(() => {
H.restore();
cy.signInAsNormalUser();
H.startNewNativeQuestion();
cy.viewport(1024, WINDOW_HEIGHT);
});
it("should not be possible to resize the native query editor too far (metabase#59075)", () => {
cy.findByTestId("drag-handle").then((handle) => {
const coordsDrag = handle[0].getBoundingClientRect();
cy.wrap(handle)
.trigger("mousedown", {
button: BUTTON_INDEX,
clientX: coordsDrag.x,
clientY: coordsDrag.y,
force: true,
})
// Drag to the bottom of the screen
.trigger("mousemove", {
button: BUTTON_INDEX,
clientX: coordsDrag.x,
clientY: WINDOW_HEIGHT + 10,
force: true,
})
.trigger("mouseup");
});
H.NativeEditor.get().then((editor) => {
const { bottom } = editor.get()[0].getBoundingClientRect();
cy.wrap(bottom).should("be.lessThan", WINDOW_HEIGHT - 50);
});
});
});

View File

@@ -54,7 +54,7 @@ describe("scenarios > auth > signin > SSO", () => {
cy.findByLabelText("Password").type("123");
cy.button("Sign in").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.contains("Password: did not match stored password");
cy.contains("did not match stored password");
});
it("should pass `redirect` search params from Google button screen to email/password screen (metabase#16216)", () => {

View File

@@ -1,6 +1,6 @@
const { H } = cy;
import { WRITABLE_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 { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
import type {
@@ -1421,17 +1421,36 @@ describe("issue #47005", () => {
H.restore("postgres-12");
cy.signInAsNormalUser();
H.createQuestion({
H.createCardWithQuery({
name: "Question A",
query: {
"source-table": ORDERS_ID,
databaseId: SAMPLE_DB_ID,
stages: [
{
source: {
type: "table",
id: ORDERS_ID,
},
},
],
},
}).then(({ body: question }) => {
H.createQuestion(
H.createCardWithQuery(
{
name: "Question B",
metadata: {
cardIds: [question.id],
},
query: {
"source-table": "card__" + question.id,
databaseId: SAMPLE_DB_ID,
stages: [
{
source: {
type: "card",
id: question.id,
},
},
],
},
},
{ visitQuestion: true },
@@ -1454,13 +1473,30 @@ describe("issue 66210", () => {
H.restore();
cy.signInAsAdmin();
H.createQuestion({
H.createCardWithQuery({
name: METRIC_NAME,
query: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
},
type: "metric",
query: {
databaseId: SAMPLE_DB_ID,
stages: [
{
source: {
type: "table",
id: ORDERS_ID,
},
aggregations: [
{
name: "Count",
value: {
type: "operator",
operator: "count",
args: [],
},
},
],
},
],
},
});
cy.visit("/");
@@ -1477,3 +1513,102 @@ describe("issue 66210", () => {
H.entityPickerModalLevel(1).findByText(METRIC_NAME).should("not.exist");
});
});
describe("issue #67903", () => {
beforeEach(() => {
cy.viewport(1000, 800);
H.restore();
cy.signInAsAdmin();
});
it("shoult not show preview table headers on top of other elements (metabase#67903)", () => {
H.startNewQuestion();
H.miniPickerBrowseAll().click();
H.entityPickerModalItem(1, "Orders").click();
H.getNotebookStep("data").findByTestId("step-preview-button").click();
H.queryBuilderHeader().findByLabelText("View SQL").click();
cy.findByTestId("table-header").should("not.be.visible");
});
});
describe("issue 68574", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
const questionDetails: NativeQuestionDetails = {
name: "Question 1",
native: {
query: "SELECT * FROM ORDERS WHERE CREATED_AT > {{ start }}",
"template-tags": {
start: {
type: "date",
name: "start",
"display-name": "Start",
id: "1",
},
},
},
parameters: [
createMockParameter({
id: "1",
slug: "start",
required: true,
name: "Start",
type: "date/single",
target: ["variable", ["template-tag", "start"]],
}),
],
};
H.createNativeQuestion(questionDetails, { wrapId: true });
});
it("should be possible to run a query for a empty required parameter without a default value (metabase#68574)", () => {
updateFormattingSettings({
date_style: "D MMMM, YYYY",
date_abbreviate: false,
});
visitQuestion("2024-01-01");
assertParameterFormat("1 January, 2024");
cy.log("change the date format");
updateFormattingSettings({
date_style: "dddd, MMMM D, YYYY",
date_abbreviate: false,
});
visitQuestion("2024-01-01");
assertParameterFormat("Monday, January 1, 2024");
cy.log("enable date abbreviation");
updateFormattingSettings({
date_style: "dddd, MMMM D, YYYY",
date_abbreviate: true,
});
visitQuestion("2024-01-01");
assertParameterFormat("Mon, Jan 1, 2024");
cy.log("even when the setting is unset, it should render a valid format");
updateFormattingSettings(undefined);
visitQuestion("2024-01-01");
assertParameterFormat("January 1, 2024");
});
function updateFormattingSettings(settings: any) {
H.updateSetting("custom-formatting", {
"type/Temporal": settings,
});
}
function visitQuestion(value: string) {
cy.get("@questionId").then((id) => {
cy.visit(`/question/${id}?start=${value}`);
});
}
function assertParameterFormat(value: string) {
cy.findByTestId("parameter-value-widget-target")
.should("be.visible")
.should("contain.text", value);
}
});

View File

@@ -487,6 +487,10 @@ describe("scenarios > dashboard > download pdf", () => {
});
H.openSharingMenu("Export as PDF");
cy.findByTestId("status-root-container")
.should("contain", "Downloading")
.and("contain", `Dashboard for saving pdf dashboard - ${date}`);
cy.log("We're adding a 'Metabase-' prefix for non-whitelabelled instances");
cy.verifyDownload(`Metabase - saving pdf dashboard - ${date}.pdf`);
});
@@ -512,6 +516,10 @@ describe("[snowplow] scenarios > dashboard", () => {
H.visitDashboard(dashboard.id);
H.openSharingMenu("Export as PDF");
cy.findByTestId("status-root-container")
.should("contain", "Downloading")
.and("contain", "Dashboard for test dashboard");
H.expectUnstructuredSnowplowEvent({
event: "dashboard_pdf_exported",
dashboard_id: dashboard.id,

View File

@@ -181,6 +181,8 @@ describe("Embed JS modal display", () => {
H.openSharingMenu("Embed");
cy.findByLabelText("Metabase account (SSO)").click();
H.embedModalEnableEmbeddingCard().within(() => {
cy.findByText(/modular embedding/).should("be.visible");
});
@@ -196,8 +198,9 @@ describe("Embed JS modal display", () => {
H.visitDashboard("@dashboardId");
H.openSharingMenu("Embed");
it("should open Embed JS modal with the `enable simple embedding` card", () => {
it("should open Embed JS modal with the `enable guest embedding` card", () => {
H.activateToken("pro-self-hosted");
H.updateSetting("enable-embedding-static", false);
H.visitDashboard("@dashboardId");
H.openSharingMenu("Embed");

View File

@@ -2,6 +2,7 @@ const { H } = cy;
import { USERS } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
import { embedModalEnableEmbedding } from "e2e/support/helpers";
const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE;
const { admin, normal } = USERS;
@@ -803,8 +804,8 @@ describe("scenarios > dashboard > subscriptions", () => {
H.openSharingMenu();
H.sharingMenu().findByRole("menuitem", { name: "Embed" }).click();
cy.findByRole("button", { name: "Agree and enable" }).click();
cy.findByLabelText("Metabase account (SSO)").click();
embedModalEnableEmbedding();
cy.findByLabelText("Allow subscriptions").check().should("be.checked");
H.getIframeBody().within(() => {
cy.button("Subscriptions").click();

View File

@@ -1120,6 +1120,9 @@ describe("scenarios > visualizations > bar chart", () => {
},
});
// Open the data reference sidebar to squish the data further
cy.findByLabelText("Learn about your data").click();
cy.wait("@dataset");
H.echartsContainer().should("be.visible");

View File

@@ -696,6 +696,99 @@ describe("scenarios > visualizations > table > dashboards context", () => {
});
});
it("should update row heights correctly when sorting with text wrapping enabled (metabase#61164)", () => {
// This test verifies that when sorting changes, row heights are recalculated
// based on the new row content at each position (not the old cached heights)
H.createQuestionAndDashboard({
questionDetails: {
name: "reviews for sorting test",
type: "model",
query: {
"source-table": SAMPLE_DATABASE.REVIEWS_ID,
limit: 10,
},
visualization_settings: {
"table.column_widths": [200, 100, 100, 100, 100],
column_settings: {
'["name","BODY"]': {
text_wrapping: true,
},
},
"table.columns": [
{
name: "BODY",
enabled: true,
},
{
name: "RATING",
enabled: true,
},
{
name: "ID",
enabled: true,
},
{
name: "PRODUCT_ID",
enabled: true,
},
{
name: "REVIEWER",
enabled: true,
},
],
},
},
dashboardDetails: {
name: "Dashboard",
},
cardDetails: {
size_x: 24,
size_y: 12,
},
}).then(({ body: { dashboard_id } }) => {
H.visitDashboard(dashboard_id);
// Wait for table to render, then sort and verify rows don't overlap
H.tableInteractive()
.find("[data-index=0]")
.should("exist")
.then(() => {
H.tableHeaderClick("Rating");
// Verify rows don't overlap by checking their bounding rects
H.tableInteractive()
.find("[role=row]")
.then(($rows) => {
const rects = $rows
.toArray()
.map((row) => row.getBoundingClientRect())
.sort((a, b) => a.top - b.top);
// Each row's top should equal the previous row's bottom (no overlap)
for (let i = 1; i < rects.length; i++) {
expect(rects[i].top).to.equal(rects[i - 1].bottom);
}
});
// Sort again (descending) to verify heights update on subsequent sorts
H.tableHeaderClick("Rating");
H.tableInteractive()
.find("[role=row]")
.then(($rows) => {
const rects = $rows
.toArray()
.map((row) => row.getBoundingClientRect())
.sort((a, b) => a.top - b.top);
for (let i = 1; i < rects.length; i++) {
expect(rects[i].top).to.equal(rects[i - 1].bottom);
}
});
});
});
});
it("should support the row index setting", () => {
H.visitDashboard(ORDERS_DASHBOARD_ID);
H.editDashboard();

View File

@@ -65,14 +65,16 @@
"Adds to `user` a set of boolean flags indicating whether or not current user has access to advanced permissions.
This function is meant to be used for GET /api/user/current."
[user]
(let [permissions-set @api/*current-user-permissions-set*
user-id api/*current-user-id*]
(let [permissions-set @api/*current-user-permissions-set*
user-id api/*current-user-id*
can-access-data-model (perms/user-has-any-perms-of-type? user-id :perms/manage-table-metadata)]
(update user :permissions assoc
:can_access_setting (perms/set-has-application-permission-of-type? permissions-set :setting)
:can_access_subscription (perms/set-has-application-permission-of-type? permissions-set :subscription)
:can_access_monitoring (perms/set-has-application-permission-of-type? permissions-set :monitoring)
:can_access_data_model (perms/user-has-any-perms-of-type? user-id :perms/manage-table-metadata)
:can_access_data_model can-access-data-model
:can_access_db_details (perms/user-has-any-perms-of-type? user-id :perms/manage-database)
:is_data_analyst api/*is-data-analyst?*
:is_group_manager api/*is-group-manager?*)))
(defenterprise current-user-has-application-permissions?

View File

@@ -20,6 +20,9 @@
{:name :sso-saml
:available (premium-features/enable-sso-saml?)
:enabled (sso-settings/saml-enabled)}
{:name :sso-slack
:available (premium-features/enable-sso-slack?)
:enabled (sso-settings/slack-connect-enabled)}
{:name :scim
:available (premium-features/enable-scim?)
:enabled (boolean (scim/scim-enabled))}

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