Compare commits

...

47 Commits

Author SHA1 Message Date
Jeff Bruemmer
8b2a3a4a16 embedding types 2025-11-03 16:34:41 -05:00
Jeff Bruemmer
793bdf79ab edits 2025-11-03 16:20:54 -05:00
Jeff Bruemmer
8acf58c464 don't complicate it 2025-11-03 15:59:03 -05:00
Jeff Bruemmer
505057635d token check info 2025-11-03 15:50:35 -05:00
github-automation-metabase
f71b169128 Restore calendar hover styles (#65361) (#65379)
* restore calendar hover styles

* fix calendar picker hover states

* remove unnecessary rule

Co-authored-by: Ryan Laurie <30528226+iethree@users.noreply.github.com>
2025-11-03 19:11:43 +00:00
github-automation-metabase
4709110720 Updates 6 illustrations for dark mode (#65375) (#65380)
Co-authored-by: AlessioLaiso <7041012+AlessioLaiso@users.noreply.github.com>
2025-11-03 19:05:44 +00:00
Jeff Bruemmer
409eb9638f Revert "Revert "docs -remote sync (#65009)" (#65305)" (#65377)
This reverts commit 70ac109c30.
2025-11-03 13:39:49 -05:00
github-automation-metabase
560aba706f Add archived flag to graph endpoints (#65366) (#65371)
Fixes QUE-2708

Co-authored-by: metamben <103100869+metamben@users.noreply.github.com>
2025-11-03 17:29:56 +00:00
github-automation-metabase
400cca7fe5 filter for active fields (#65245) (#65360)
Co-authored-by: Riley Thompson <riley@metabase.com>
2025-11-03 15:46:21 +00:00
github-automation-metabase
226ab1bf62 Fix suggested prompts schema and add test (#65299) (#65355)
Co-authored-by: lbrdnk <lbrdnk@users.noreply.github.com>
2025-11-03 15:38:56 +00:00
github-automation-metabase
a97dc5cf61 Compute param values from card results (#64318) (#65354)
* Compute param values from card results

seq for bool

Tests, multi-stage queries

Compute values also for params on nested stages

whitespace

Unused ns

Last stage check first

Docstring

Drop base query's last stage clauses

Address review remarks

Apply suggestions from code review



Remove redundant ops

Adjust for test

Search for col also in aggregated result columns

Look for breakoutable column only if source-uuid is present

* symb

* Rebase fix

* fix rebase, remove unused module

* post rebase, fix getting database id

* kondo

* Revamp

* limit

* Adjust the solution for impl joins

* Fix e2e

* Comment

* Cleanup

* Cleanup

* comment

* comment

* Update src/metabase/parameters/dashboard.clj



* indent

---------

Co-authored-by: lbrdnk <lbrdnk@users.noreply.github.com>
Co-authored-by: metamben <103100869+metamben@users.noreply.github.com>
2025-11-03 15:28:40 +00:00
github-automation-metabase
c05f5ec9eb UXW-175 Fix visualization mode switch for pivot table (#65252) (#65352)
Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2025-11-03 15:06:52 +00:00
Jeff Bruemmer
536e71d4cb docs - 57 api docs (#65308)
* api docs

* env var
2025-11-03 08:46:58 -05:00
github-automation-metabase
f04c507ac4 Disable CodeQL trap caching (#65275) (#65337)
Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>
2025-11-03 09:08:08 +00:00
github-automation-metabase
949cddcd2b Fix #64685 - Update brand colors in UI without needing refresh (#65292) (#65323)
* UXW-1991 Fixes various issues both new and old with the Branding page like the colors not updating until refresh and swatches not updating when setting colors back to their defaults

* UXW-1991 Uses hex format for brand colors placeholders

* UXW-1991 Fix persistence issues with setting/unsetting accent colors

Co-authored-by: Brad Anderson <brad@metabase.com>
2025-10-31 23:16:09 +00:00
github-automation-metabase
cc8e2c332a UXW-2183 Remove fullscreen dashboard night mode toggle (#65312) (#65321)
Co-authored-by: Brad Anderson <brad@metabase.com>
2025-10-31 22:53:37 +00:00
Ryan Laurie
3ae7763a1b Update translations 2025-10-30 (#65213) (#65314)
* Update translation sources 2025-10-30

* Update translations

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-10-31 23:48:39 +01:00
github-automation-metabase
3edc9d2259 Remove mention of "night mode" and image (#65318) (#65319)
* Remove mention of "night mode" and image

Removed mention of 'Night mode' from fullscreen dashboard instructions.

* Delete dark mode/night mode image

Co-authored-by: Robert Kosara <rkosara@me.com>
2025-10-31 21:50:02 +00:00
dpsutton
a0e7336119 Don't count app-db not setup errors in circuit breaker (#65300)
we want to not hammer the network when things are down. But during
startup we don't need to circuit break these errors that we throw before
the app-db is read
2025-10-31 16:33:34 -05:00
github-automation-metabase
d810202021 Implement connection impersonation for starburst (#64972) (#65306)
* implemenet impersonation driver methods

* replace starburst impersonation

* use user as default role

* clean up

* enable starburst tests

* mount files to container

* add config files

* fix rules.json

* add starburst impersonation tests

* try fix

* remove old tests

* debug

* Debug'

* add volumes original way

* try again

* revert

* try docker run

* remove services

* add back accessc control

* enable impersonation

* exclude starburst tests again

Co-authored-by: Riley Thompson <riley@metabase.com>
2025-10-31 20:14:50 +00:00
github-automation-metabase
82ede379eb Small edits to v57 docs about Glossary, dark mode, and progress bars (#65253) (#65309)
* Enhance glossary section with context for definitions

This is a smaller change than I expected, since glossary terms don't show up in searches yet. Might just cut this if it doesn't add anything useful. I also wasn't sure if we should mention Metabot here.

* Add dark mode options

Adds the available options and explains that you don't have to reload the page

* Add a little clarification to the computed goal example

This confused me, so I'm adding this little clarification

* Jeff's suggested change



* Remove the bit about LLMs again

---------

Co-authored-by: Robert Kosara <rkosara@me.com>
Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2025-10-31 19:01:09 +00:00
Jeff Bruemmer
70ac109c30 Revert "docs -remote sync (#65009)" (#65305)
This reverts commit c257c954fb.
2025-10-31 14:49:32 -04:00
github-automation-metabase
9a277a649e Metabot glossary integration (#65223) (#65274)
* Return glossary with user info

* Test glossary in response

Co-authored-by: lbrdnk <lbrdnk@users.noreply.github.com>
2025-10-31 16:58:01 +01:00
Nemanja Glumac
c43fc5726e Fix logs upload for drivers (#65257) (#65289)
* Fix logs upload for drivers

When we introduced `continue-on-error` to the "test" step, we had to
update the conditionals for the test report steps as well. I've
introduced a bug by checking for a failure, instead of saying - upload
test reports whenever the job is not successful.

Hint: test.outcome means "step status BEFORE continue-on-error overrides
the exit code"
2025-10-31 16:40:55 +01:00
github-automation-metabase
b21072994c Remove superfluous python setup from H2 driver test (#65272) (#65284)
Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>
2025-10-31 16:23:40 +01:00
github-automation-metabase
9ab49864c1 fix: pivoting json values (#65183) (#65278)
* fix: pivoting json values

* fixup! kondos in test

* Update frontend/src/metabase/lib/data_grid.js



---------

Co-authored-by: Edward Paget <edpaget@users.noreply.github.com>
Co-authored-by: Ryan Laurie <30528226+iethree@users.noreply.github.com>
2025-10-31 14:39:02 +00:00
github-automation-metabase
8e02fb7dd2 Fix: UXW-2171 Prevents "Edit Query" from clearing name on chart (#65224) (#65280)
Co-authored-by: Brad Anderson <brad@metabase.com>
2025-10-31 14:16:54 +00:00
github-automation-metabase
b53131f473 Sunset e2e cross-version tests (#65269) (#65277)
Context: https://metaboat.slack.com/archives/C0669P4AF9N/p1761905881175819

The requirements for new and updated attempt at solving this problem are documented in:
https://linear.app/metabase/issue/DEV-1042/design-reliable-cross-version-upgradedowngrade-test-strategy

Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>
2025-10-31 14:11:29 +00:00
github-automation-metabase
4805c9f244 Fix deleted documents were editable (#65136) (#65276)
* Prevent trashed documents from being edited

* Add test

* Soften the test name tone

* Disallow even updating document when archived via API

* Add BE test

* Fix Cypress test issue reference

* Update tests after merging master

* Only check for archived when not unarchiving

* Don't check archived documents when archiving or unarchiving them

* Add missing condition from merging

Co-authored-by: Mahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev>
2025-10-31 12:59:29 +00:00
github-automation-metabase
894a2fb895 update hover colors in UserListElement (#65174) (#65243)
* update hover colors in UserListElement

* adjusting colors

* updating comment

Co-authored-by: Nick Fitzpatrick <nick@metabase.com>
2025-10-31 09:17:34 -03:00
github-automation-metabase
52517517f5 Fix env var doc gen (#65230) (#65270)
* fix issue with env var gen and some cleanup

* cleanup

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2025-10-31 11:42:20 +00:00
github-automation-metabase
52aba51798 remove registry (#65242) (#65264)
Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2025-10-31 03:46:19 +00:00
github-automation-metabase
9a33050c61 Optimize graph API endpoints (#65172) (#65263)
Co-authored-by: metamben <103100869+metamben@users.noreply.github.com>
2025-10-31 02:55:16 +00:00
github-automation-metabase
381fa1fdbd Upgrade CodeQL action version (#65250) (#65261)
Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>
2025-10-31 01:16:46 +00:00
github-automation-metabase
bf17242cb9 [Metabot] Copy change for suggested prompts setting description (#65163) (#65237)
* update copy

* adjust copy for embedded metabot

* oops

* remove randomly

* fix test

* feedback

Co-authored-by: Sloan Sparger <sloansparger@users.noreply.github.com>
2025-10-30 21:06:31 +00:00
github-automation-metabase
f1336e039f update document title compare to determine dirty state (#65229) (#65246)
Co-authored-by: Nick Fitzpatrick <nick@metabase.com>
2025-10-30 20:44:54 +00:00
github-automation-metabase
1233934a0d fix falsey check when removing card embed from document (#65222) (#65244)
Co-authored-by: Nick Fitzpatrick <nick@metabase.com>
2025-10-30 20:32:53 +00:00
github-automation-metabase
5bdc6d7000 fix(sdk): non-editable dashboard should not have "add a chart" button and title should be read-only (#65102) (#65241)
Co-authored-by: Phoomparin Mano <poom@metabase.com>
2025-10-30 20:21:24 +00:00
github-automation-metabase
81ac3c9ae5 return false instead of throw (#65225) (#65235)
Co-authored-by: Riley Thompson <riley@metabase.com>
2025-10-30 19:39:21 +00:00
github-automation-metabase
dba578efad GDGT-1083: Improve visual design for new List view in Stats before v57 (#64957) (#65226)
* GDGT-1083: Add new icons

* Remove subtitle column. Restyle inputs. Extend icon selection

* GDGT-1083: Fix minor tags input styling issues

* GDGT-1083: Replace semantic colors with accent colors

* GDGT-1083: Add image column selection option

* GDGT-1083: Add placeholder for image column icon. Fix bug with selected icon

* GDGT-1083: Update formatted column values rendering rules

* GDGT-1083: Fix incorrect entity icon initialization

* GDGT-1083: Add range viz rendering for quantity/score columns

* GDGT-1083: Fix drag from ListViewColumnsSidebar

* GDGT-1083: Fix list header grid

* GDGT-1083: Fix State column styling

* GDGT-1083: Fix incorrect rendering for boolean columns

* GDGT-1083: Fix incorrect column rendering for empty cells

* GDGT-1083: Address design review comments

* GDGT-1083: Fix TS error

* GDGT-1083: Fix image column rendering

* GDGT-1083: Minor refactoring

* GDGT-1083: Update e2e est

* GDGT-1083: Add unit test for ListViewItem component

* GDGT-1083: Fix linter

* Fix eslint

* GDGT-1083: Resolve metabot comments

* GDGT-1083: Get rid of split_currency setting

* wip

* GDGT-1083: Address PR comments

* GDGT-1083: Address PR comments

* GDGT-1083: Replace switch logic with fns. Fix header layout bug

* GDGT-1083: Fix badges cursor in list view

* GDGT-1083: Fix cursor styling when dragging over full tags input

* GDGT-1083: Remove max-width

* GDGT-1083: Fix linter

* GDGT-1083: Fix lint

* GDGT-1083: Fix incorrect imgURL/avatarURL column rendering after refactoring

* Fix incorrect icon background color

* Fix incorrect category columns rendering

* Update Loki Snapshots

---------

Co-authored-by: Stas Gavrylov <stas@metabase.com>
2025-10-30 19:38:02 +00:00
Denis Berezin
0d66452695 UXW-2091 user without collection access to Our Analytics can't save documents (#65159) (#65216)
* UXW-2091 user without collection access to Our Analytics can't save documents

* fix typecheck

* Fix types

* Add e2e tests

(cherry picked from commit 9f0f6eb78c)
2025-10-30 22:11:31 +03:00
dpsutton
9edb741033 Token grace period (#64938) (#65208)
* Add a grace period to token checks

There's more work on this namespace to be done i think, but this is a
first cut at it. There's a lot of control flow by exceptions which is
how we jump around in here. Fundamentally this should be far
simpler. Will take a crack at that shortly. I also don't love that this
just hammers the `fetch-token-status` function and then memoizes inside
of that. Would prefer simpler layout of `token-features` function and
that can be aware of population issues.

Adds a simple protocol: GracePeriod, which has two functions: save! and
retrieve. On successful token check, we `save!` the response. On errors,
we `retrieve` previous responses. This obviously can be nil in the case
we have never had a successful response, and will become nil once the
grace period's internal notion of time expires.

This implementation is based on guava's cache which has a nifty
expireafterwrite functionality.

```
 (*token-features*) ;; call this function to simulate the check of features
Checking with the MetaStore to see whether token 'f325...d01a' is valid...
Reporting Metabase stats: {... user counts etc}
 -> #{"features come back"}
 ;; meanwhile clear cache to simulate elapsing of 12 hours

 (*token-features*) ;; call function again to simulate check of features
2025-10-22 21:30:26,620 INFO premium-features.token-check :: Checking with the MetaStore to see whether token 'f325...d01a' is valid...
2025-10-22 21:30:26,622 ERROR premium-features.token-check :: Error fetching token status from https://token-check.metabase.com:
clojure.lang.ExceptionInfo: network issues ;; hit network issue

2025-10-22 21:30:26,632 INFO premium-features.token-check :: Using token from grace period
->> #{graceperiod network features}
...

;; here after 5 seconds (in real life will be 36 hours)
2025-10-22 21:31:00,064 WARN premium-features.token-check :: Removing token: f325...d01a from grace period cache {mb-quartz-job-type=Cache}
```

* tests that updating value populates grace period cache

* couple changes

protect against storing nil value. guava cache doesn't like it so gone
it is.

added some tests:

```clojure
(testing "Setting a new value populates the grace period cache"
    (let [new-token (tu/random-token)]
      (binding [http/request (fn [& _] {:status 200
                                        :body "{\"valid\":true,\"status\":\"fake\",\"features\":[\"fake\",\"features\"]}"})]
        (mt/discard-setting-changes [premium-embedding-token]
          (premium-features.settings/premium-embedding-token! new-token)
          (is (= #{"fake" "features"} (token-check/*token-features*)))
          (is (= {:valid true :status "fake" :features ["fake" "features"]}
                 (token-check/retrieve token-check/grace-period new-token)))))))
```

But this ended up leaving a setting table value for this value. i don't
feel like looking into discard setting changes so i'm gonna just remove it

* attempt to simplify the api a bit

the thee important functions are now `token-information`, `harness` and
`decode-token`.

Everyone should use token-information. Harness hooks up the grace period
and retry strategies, and `decode-token` only cares about decoding a
token. It should return the token-status map {:valid true ...} or throw.

* fixup test

* clean up tests and rename a bit

* remove comment block

* remove commented out grace period

* bit of cleanup

* fix with-random-premium-token! macro

* function composes them instead of caller

rather than a caller needing to know all of the layers, i thread them
for you and let you provide all arugments in one function call.

dynamic var for needing to customize layers in tests. largely for
removing the circuit-breaker which is stateful and hard to restart if
you want to simulate errors in tests

* rename test
2025-10-30 13:59:20 -05:00
github-automation-metabase
c62d68c201 Update error message for remote sync dep checking (#65148) (#65217)
* Update error message for remote sync dep checking

Try to make these error messages less jargony because they are presented
by the user interface when dependency checking content fails.

Closes UXW-2142 and UXW-1879.

* Update some missed tests

Co-authored-by: Edward Paget <edpaget@users.noreply.github.com>
2025-10-30 17:35:45 +00:00
github-automation-metabase
6390717ce9 fix: improve collections graph performance (#65098) (#65211)
* fix: improve collections graph performance

Fixes performance generating and updating a collection graph by avoiding
the cross join of all negative permissions (blocks) between groups and
collections. Instead it builds sparse graph where missing entries for
group/collection paris can be assumed to be :none permissions.

In practice this drops a collection permission update on a graph of 5K
groups and 5K collections from a greater than 10min operation on my
local machine against MySQL to a .5s operation.

* fixup! explicilty disj :root in other places

* fixup! improve some docs

* fixup! improve more docs

* fixup! seq checks

* fixup! blank lines

* fixup! convert raw sql concat into honeysql

* fixup! e2e test

Co-authored-by: Edward Paget <edpaget@users.noreply.github.com>
2025-10-30 17:10:19 +00:00
github-automation-metabase
27abb898a5 Create appdb tables in its own connection (#65171) (#65219)
Co-authored-by: Nathan Voxland <nathan@metabase.com>
2025-10-30 16:43:59 +00:00
Alex Yarosh
c70bd40481 remove transforms (#65221) 2025-10-30 11:53:29 -04:00
github-automation-metabase
2e9f645974 remove border from dashboard header where header=false and no tabs (#65161) (#65214)
* remove border from dashboard header where header=false and no tabs

* move css code up to keep no border in fullscreen

Co-authored-by: Nicolò Pretto <info@npretto.com>
2025-10-30 15:43:17 +00:00
207 changed files with 581152 additions and 509370 deletions

View File

@@ -1735,6 +1735,7 @@
lib-be
models
native-query-snippets
permissions
premium-features
queries
query-processor

View File

@@ -35,7 +35,10 @@ runs:
continue-on-error: ${{ !(github.event_name == 'pull_request' && github.event.pull_request.draft == true) }}
- name: Publish Test Report (JUnit)
uses: dorny/test-reporter@v1
if: ${{ steps.run-test.outcome == 'failure' }}
# As per the documentation: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#steps-context
# test.outcome gives us access to the result before `continue-on-error` overrides it and sets it to 'success'.
# We want test reports even if the test step gets cancelled (e.g. times out), hence we match anything that is not a 'success'.
if: steps.run-test.outcome != 'success'
with:
path: "target/junit/**/*_test.xml"
name: JUnit Test Report ${{ inputs.junit-name }}
@@ -44,7 +47,7 @@ runs:
list-tests: failed
fail-on-error: false
- name: Upload Logs on Failure
if: ${{ steps.run-test.outcome == 'failure' }}
if: always() && steps.run-test.outcome != 'success'
uses: actions/upload-artifact@v4
with:
name: test-logs-${{ github.job }}

View File

@@ -1,3 +1,5 @@
version: 1
name: "Metabase CodeQL config"
paths-ignore:

View File

@@ -127,10 +127,6 @@ e2e_ci: &e2e_ci
e2e_specs: &e2e_specs
- e2e/!(test-host-app)**/*.cy.*.{js,ts}
e2e_cross_version: &e2e_cross_version
- ".github/workflows/e2e-cross-version.yml"
- "e2e/test/scenarios/cross-version/**"
e2e_embedding_sdk:
- *frontend_embedding_sdk_ci
- *sources
@@ -170,6 +166,7 @@ yaml:
- "**/*.yaml"
codeql:
- ".github/workflows/codeql.yml"
- "frontend/src/**"
- "enterprise/frontend/src/**"

View File

@@ -30,9 +30,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v4
with:
languages: javascript
config-file: ./.github/codeql/codeql-config.yml
trap-caching: false
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v4

View File

@@ -390,10 +390,6 @@ jobs:
name: H2
steps:
- uses: actions/checkout@v4
- name: Setup python-runner
uses: ./.github/actions/setup-python-runner
with:
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
- name: Test H2 driver
id: test-driver
uses: ./.github/actions/test-driver
@@ -413,12 +409,6 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
with:
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
be-tests-mysql-mariadb:
if: ${{ !inputs.skip }}

View File

@@ -1,175 +0,0 @@
name: E2E Cross-version Tests
on:
schedule:
- cron: '0 9 * * 1-5'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
files-changed:
name: Check which files changed
# don't run this workflow on a cron for forks
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
runs-on: ubuntu-22.04
timeout-minutes: 3
outputs:
e2e_cross_version: ${{ steps.changes.outputs.e2e_cross_version }}
steps:
- uses: actions/checkout@v4
- name: Test which files changed
uses: dorny/paths-filter@v3.0.0
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
cross-version-testing:
needs: files-changed
if: ${{ needs.files-changed.outputs.e2e_cross_version == 'true' || github.event_name == 'schedule'}}
runs-on: ubuntu-22.04
timeout-minutes: 20
strategy:
matrix:
version: [
# Major OSS upgrade
{ source: v0.42.2, target: v0.48.7 },
# OSS upgrade
{ source: v0.41.3.1, target: v0.42.2 },
# EE upgrade
{ source: v1.43.4, target: v1.44.0 },
# EE downgrade
{ source: v1.42.0, target: v1.41.3.1 },
# Cross-edition upgrade
{ source: v0.41.3.1, target: v1.42.2 }
]
fail-fast: false
env:
CROSS_VERSION_SOURCE: ${{ matrix.version.source }}
CROSS_VERSION_TARGET: ${{ matrix.version.target }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set source Docker image to OSS
run: |
echo "DOCKER_SOURCE_IMAGE=metabase/metabase:${{ matrix.version.source }}" >> $GITHUB_ENV
if: startsWith(matrix.version.source, 'v0')
- name: Set source Docker image to EE
run: |
echo "DOCKER_SOURCE_IMAGE=metabase/metabase-enterprise:${{ matrix.version.source }}" >> $GITHUB_ENV
if: startsWith(matrix.version.source, 'v1')
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Start Metabase ${{ matrix.version.source }}
run: |
docker run -d \
-v $PWD/my-metabase:/metabase.db \
-p 3000:3000 \
-e MB_RUN_MODE=e2e \
-e METASTORE_DEV_SERVER_URL=${{ secrets.METASTORE_DEV_SERVER_URL }} \
-e CYPRESS_MB_ALL_FEATURES_TOKEN=${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }} \
-e CYPRESS_MB_STARTER_CLOUD_TOKEN=${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }} \
-e CYPRESS_MB_PRO_CLOUD_TOKEN=${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }} \
-e CYPRESS_MB_PRO_SELF_HOSTED_TOKEN=${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }} \
--name metabase-${{ matrix.version.source }} \
${{ env.DOCKER_SOURCE_IMAGE }}
- name: Wait for Metabase to start on port 3000
run: while ! curl -s 'http://localhost:3000/api/health' | grep '{"status":"ok"}'; do sleep 1; done
- name: Prepare Cypress environment
id: cypress-prep
uses: ./.github/actions/prepare-cypress
- name: Run Cypress on the source
run: |
yarn cypress run \
--browser ${{ steps.cypress-prep.outputs.chrome-path }} \
--config-file e2e/test/scenarios/cross-version/source/shared/cross-version-source.config.js \
--spec e2e/test/scenarios/cross-version/source/**/*.cy.spec.js
- name: Stop Metabase ${{ matrix.version.source }}
run: docker stop metabase-${{ matrix.version.source }}
- name: Set target Docker image to OSS
run: |
echo "DOCKER_TARGET_IMAGE=metabase/metabase:${{ matrix.version.source }}" >> $GITHUB_ENV
if: startsWith(matrix.version.source, 'v0')
- name: Set target Docker image to EE
run: |
echo "DOCKER_TARGET_IMAGE=metabase/metabase-enterprise:${{ matrix.version.source }}" >> $GITHUB_ENV
if: startsWith(matrix.version.source, 'v1')
- name: Start Metabase ${{ matrix.version.target }}
run: |
docker run -d \
-v $PWD/my-metabase:/metabase.db \
-p 3001:3000 \
-e MB_RUN_MODE=e2e \
-e METASTORE_DEV_SERVER_URL=${{ secrets.METASTORE_DEV_SERVER_URL }} \
-e CYPRESS_MB_ALL_FEATURES_TOKEN=${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }} \
-e CYPRESS_MB_STARTER_CLOUD_TOKEN=${{ secrets.STAGING_MB_STARTER_CLOUD_TOKEN }} \
-e CYPRESS_MB_PRO_CLOUD_TOKEN=${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }} \
-e CYPRESS_MB_PRO_SELF_HOSTED_TOKEN=${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }} \
--name metabase-${{ matrix.version.target }} \
${{ env.DOCKER_TARGET_IMAGE }}
- name: Wait for Metabase to start on port 3001
run: while ! curl -s 'http://localhost:3001/api/health' | grep '{"status":"ok"}'; do sleep 1; done
- name: Run Cypress on the target
run: |
yarn cypress run \
--browser ${{ steps.cypress-prep.outputs.chrome-path }} \
--config-file e2e/test/scenarios/cross-version/target/shared/cross-version-target.config.js \
--spec e2e/test/scenarios/cross-version/target/**/*.cy.spec.js
- name: Upload Cypress Artifacts upon failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-artifacts-${{ matrix.version.source}}-to-${{ matrix.version.target }}
path: |
./cypress
if-no-files-found: ignore
notify-on-failure:
needs: cross-version-testing
if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name == 'schedule' }}
name: Cross-version test failure
runs-on: ubuntu-22.04
timeout-minutes: 5
steps:
- uses: actions/setup-node@v4
with:
node-version: lts/Jod # 22.x.x
- run: npm install @slack/web-api
- name: notify slack of failure
uses: actions/github-script@v7
with:
script: | # js
const FAILED_RUN_URL = "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}";
const REF_NAME = "${{ github.ref_name }}";
// notify slack of failure
const { WebClient } = require('@slack/web-api');
const slack = new WebClient('${{ secrets.SLACK_BOT_TOKEN }}');
await slack.chat.postMessage({
channel: 'engineering-ci',
text: 'Cross-version Tests Have Failed',
blocks: [
{
"type": "header",
"text": {
"type": "plain_text",
"text": `:broken_heart: Cross-version tests are failing on ${REF_NAME}`,
"emoji": true,
}
},
],
attachments: [{
color: "#BF40BF",
blocks: [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `See <${FAILED_RUN_URL}|failing run details>.`
}
},
]
}]
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

File diff suppressed because one or more lines are too long

View File

@@ -30,7 +30,13 @@ Appearance settings are split across different tabs:
## Dark mode
People can display their Metabase in dark mode in their [account settings](../people-and-groups/account-settings.md#theme). Dark mode is a user-level setting, not an instance-level setting. Currently, there's no way to change the theme to dark mode for the entire instance, but you can edit some [user interface colors](#user-interface-colors).
People can display their Metabase in dark mode in their [account settings](../people-and-groups/account-settings.md#theme). Options are:
- System default (which is also the default setting). Metabase will switch between light and dark mode when the system switches without having to reload the page.
- Always use dark mode.
- Always use light mode.
Dark mode is a user-level setting, not an instance-level setting. Currently, there's no way to change the theme to dark mode for the entire instance, but you can edit some [user interface colors](#user-interface-colors).
## Branding

View File

@@ -1950,7 +1950,7 @@ Maximum number of async Jetty threads. If not set, then [MB_JETTY_MAXTHREADS](#m
Type: integer<br>
Default: `20`<br>
Limits the number of rows Metabase will display in tables sent with dashboard subscriptions and alerts. Range: 1-100. To limit the total number of rows included in the file attachment for an email dashboard subscription, use [MB_UNAGGREGATED_QUERY_ROW_LIMIT](#mb_unaggregated_query_row_limit).
Limits the number of rows Metabase will display in tables sent with dashboard subscriptions and alerts. Range: 1-100. To limit the total number of rows included in the file attachment for an email dashboard subscription, use [MB_ATTACHMENT_ROW_LIMIT](#mb_attachment_row_limit).
### `MB_AUDIT_MAX_RETENTION_DAYS`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -283,9 +283,7 @@ If you don't see the palette icon, you'll need to:
After you've made your ideal dashboard, you may want to put the dashboard on a TV to help keep your team up to date throughout the day.
To enter fullscreen mode, click the **fullscreen** icon in the top right of the dashboard (the icon with the arrows pointing in opposite directions). Once you've entered fullscreen mode, you can also switch the dashboard into "Night mode" for higher contrast.
![Night mode](images/dark-mode.png)
To enter fullscreen mode, click the **fullscreen** icon in the top right of the dashboard (the icon with the arrows pointing in opposite directions).
## Auto refresh

View File

@@ -1,85 +0,0 @@
---
title: Python transforms
summary: Use Python to wrangle your data in Metabase and write the results back to your database.
---
# Python transforms
> Python transforms are only available on Metabase Cloud [Pro and Enterprise plans](https://www.metabase.com/pricing) with the **Python execution add-on**.
You can write a Python script to transform the data from your database, and write the result back into the database as another table.
For general information about Metabase transforms, see [Transforms](transforms.md).
## Create a Python transform
To write Python transforms, you'll need to have the Python execution add-on. Once you've enabled the add-on:
1. Go to **Admin settings > Transforms** and click **Create a transform > Python script**.
2. Select a database that has the data you want to transform. See [Databases that support transforms](transforms.md#databases-that-support-transforms).
3. Select one or more tables with the data that you'd like to transform. Optionally, assign aliases to the tables.
The tables you select will be available as DataFrames in your Python code with your chosen aliases, and will be passed to the `transform()` function as parameters.
4. Create a function `transform()` that does the data wrangling and returns a [pandas DataFrame](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html). The DataFrame returned by the function will be written back to your database when the transform is run.
5. Click **Save** in the top right corner.
6. Select a target schema for your transform and enter a name for the target table. Metabase will write the results of the transform into this table.
You can only write back to the same database as you chose for the transform source.
7. Optionally, assign tags to your transforms. Tags are used by [jobs](transforms.md#jobs-and-tags) to run transforms on schedule.
## Tips for Python transforms
- A Python transform must have a function `transform()` that returns a single `pandas` DataFrame.
- You can use aliases to include tables from your database as DataFrames inside the `transform()` function. The tables will _only_ be available in the transform function. Other functions won't have access to the tables.
- Only `pandas` will be imported by default, but you can import [certain other packages](#available-python-packages). You can also use functions from the [common library](#common-python-library).
- You'll see the output of `print()` statements in the transform's logs.
- Metabase won't write DataFrame indexes to the database, including indexes created by `groupby()`. If you're using a custom index that you'd like to include in the target table, you'll need to [reset index](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.reset_index.html) on your DataFrame inside the `transform()` function.
## Run a Python transform
See [Run a transform](transforms.md#run-a-transform). You'll see logs for a transform run (including the output of any `print()` statements you included in the `transform()` code) on the transform's page.
## Available Python packages
You can import any of the following Python packages in Python transforms:
- [`pandas`](https://pandas.pydata.org/);
- Any dependencies of `pandas`, e.g. [`numpy`](https://numpy.org/);
- Any packages from the [Python standard library](https://docs.python.org/3/library/index.html), e.g. `json` or `datetime`.
Due to security considerations for the Python execution environment, you won't be able to install or import any other packages.
You can also write your own functions and add them to the [common library](#common-python-library) in Metabase, which will be available across all your Python transforms.
## Common Python library
If you have functions or classes you'd like to reuse across multiple transforms, you can add them to the common library that will be available across all Python transforms.
To add things to the common Python library:
1. Go to **Admin > Transforms > Python library**.
2. Add a Python function or class.
Functions in this library can't access any data in your database.
To use functions or classes from your Python library:
1. When editing your Python transform script, check **Import common library** in the top right.
This will add an aptly named import `import common` to the transform's code.
2. Reference functions or classes from the common library in your code like `common.manifest_kittens()`.
## Current limitations of Python transforms
- Currently, Python transforms are only available on Metabase Cloud.
- The transform function must return a single `pandas` DataFrame. Other data manipulation and DataFrame libraries like `polars` or `pyspark` are not supported.
- DataFrame indexes, including indexes created by `groupby()`, are ignored from writing back to the database. If you're using a custom index that you'd like to include in the target table, you'll need to [reset index](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.reset_index.html) on your DataFrame inside the `transform()` function to make the index into a real column.
- You can't preview the results of a Python transform.
- Only a [limited set of packages](#available-python-packages) are available for import. You can't install additional packages.
- Because Python transforms use `pandas`, all data manipulation is done in memory. The available memory is determined by the Python execution add-on. For large datasets, consider using [query-based transforms](transforms.md#query-based-transforms) that run in your database.

View File

@@ -1,176 +0,0 @@
---
title: Transforms
summary: Transforms allow you to wrangle your data in Metabase, write the query results back to your database, and reuse them in Metabase as sources for new queries.
---
# Transforms
{% include plans-blockquote.html feature="Transforms" is_plural=true%}
_Admin settings > Transforms_
Transforms can be used to, well, transform your data - do stuff like preprocessing, cleaning, joining tables, pre-computing metrics. Transforms give you the ability to do the "T" of "ETL" within Metabase.
You'll write a query or a Python script in Metabase, a transform will run this query or script, create a table in your target database containing the results, and sync that table to Metabase, so it can be used as a data source for questions or other transforms.
## Transforms overview
- **Transforms** are queries (either with SQL or the query builder) or Python scripts that write back to your database as a new, persistent table. Use transforms to clean, join, or pre-aggregate data.
- Transforms are scheduled and organized using **tags** and **jobs**.
- You assign tags (e.g., daily, hourly) to group your transforms.
- A job runs on a schedule (e.g., every day at midnight) and executes all transforms that have been assigned a specific tag.
- Each execution of a transform is a **run**. A run replaces the target table with fresh results. You can review the history of runs to monitor their success or failure.
## Databases that support transforms
Currently, Metabase can create transforms on the following databases:
- BigQuery
- ClickHouse (only ClickHouse Cloud)
- MySQL/MariaDB
- PostgreSQL
- Redshift
- Snowflake
- SQL Server
You can't create transforms on databases that have [Database routing](../permissions/database-routing.md) enabled, on Metabase [Cloud Storage](../cloud/storage.md), or on Metabase's Sample Database.
Transforms will create tables in your database, so the database user you use for your connection must have appropriate privileges. See [Database users, roles, and privileges](../databases/users-roles-privileges.md).
## Types of transforms
Metabase supports two types of transforms: query-based transforms and Python transforms. Here's a high-level overview of how they work and the differences between them:
### Query-based transforms
- In Metabase, you create a `SELECT` query either using SQL or Metabase's [graphical query builder](../questions/query-builder/editor.md).
- When the transform first runs, your _database_ executes the transform's query.
- Your database writes the results of the query as a new table.
- The new table is synced to Metabase.
- On subsequent transform runs, your database will overwrite that table with the updated results (updates are not incremental).
### Python-based transforms
Python-based transforms require a dedicated Python execution environment, so you'll need to buy the Python execution add-on.
- In your Metabase, you write a Python script that returns a `pandas` DataFrame and uses one or more tables from your database.
- When Metabase runs the transform, a new Python execution environment is spun up. Python transforms run in a separate, isolated environment—not on your Metabase instance.
- Metabase securely copies your source data to your Python environment and makes it available as pandas DataFrames.
- The Python environment executes your Python script _in memory_.
- The Python environment saves the resulting DataFrame as a file.
- Your Metabase instance reads the file and writes the results to a new table in your database.
- The new table is synced to Metabase.
- On subsequent transform runs, your database will overwrite that table with the updated results (updates are not incremental).
For more on Python transforms, see [Python transforms](python-transforms.md).
## Create a transform
Only admins can create transforms.
To create a transform:
1. Go to Admin settings > Transforms.
2. Click **+ Create transform**.
3. Select the source for your transform.
You can create transforms using the query builder, SQL editor, or by copying an existing question or model. On Metabase Cloud with the Python execution add-on, you can also [create transforms from a Python script](python-transforms.md). See [Types of transforms](#types-of-transforms).
If you select a question for the transform's source, Metabase will only _copy_ the question's query. Later edits to that question won't affect the transform's query.
Your transform can use the target tables of other transforms.
4. Edit the query or the Python code for the transform.
In SQL transforms, you can reference other saved questions and use snippets, but you can't use SQL parameters.
When editing query-based transforms, you can preview results without writing data into your database.
For editing Python transforms, see [Python transforms](python-transforms.md).
5. Click **Save** in the top right corner.
6. Select a target schema for your transform and enter a name for the target table. Metabase will write the results of the transform into this table.
You can only transform data _within_ a database; you can't write from one database to another.
7. Optionally, assign tags to your transforms. Tags are used by [jobs](#jobs-and-tags) to run transforms on schedule.
## Edit a transform
You can edit the transform's name and description, query or script, and target table. You can edit the transform even if it already ran or is scheduled to run.
### Edit transform's query or script
To edit the transform's query or script, click on "Edit query" or "Edit script" above the transform definition.
Currently, you can't convert between different transform types (e.g. a query builder transform to a SQL-based transform, or a SQL transform into a Python transform). If you want to change your transform built with the query builder into a SQL transform, you'll need to create a new transform with the same target and tags, and delete the old transform.
Once you change the transform's query or script, the next transform run (manual or scheduled) will use the updated query and write the results into the target table. If you have questions that query the transform's target table, they might break. For example, if your new transform query no longer includes a column that a downstream question was relying on, that question will break.
### Edit transform's target
To edit transform's target table, i.e., the table where the query results are written, click on "Change target". You'll need to select whether you want to keep the old target table, or delete it. Deletion can't be undone.
### Edit transform's name or description
To edit a transform's name or description, click on the name or description at the top of the transform page.
## Run a transform
You can run a transform manually or on schedule (e.g., hourly).
Running a transform for the first time will create and sync the table created by the transform, and you'll be able to edit the table's [metadata](./metadata-editing.md) and [permissions](../permissions/data.md). Subsequent runs will drop and recreate the table. Currently, Metabase doesn't support incremental transforms. Each transform run will recreate the target table using the entirety of the query results.
To run a transform manually, visit the transform and click **Run transform**.
To schedule a transform, you'll need to assign one or more tags to it, then create a [scheduled job](#jobs-and-tags) that picks up those tags.
You can see the time and status of the latest transform run on the transform's page, or in the Runs view. The time of the run is given in the system's timezone.
For Python transforms, you'll also see the transform's execution logs.
## Transform dependencies
Transform queries can use the data from other transforms. For example, you can have a transform that uses data from a `raw_events` table and writes to a `stg_events` table, and then create another transform that uses data from the `stg_events` table and writes to an `events` table.
Metabase will track transform dependencies, and execute them in order. For each transform, you can see which transforms the current transform depends on.
If a [job](#jobs-and-tags) includes a transform that depends on a table created by another transform, then the job will run all the tagged transforms and their dependencies, even if they lack tags.
## Jobs and tags
Jobs let you run multiple transforms on a schedule.
You can see and filter the list of all jobs, their last and next run, and the status of the least run, in _Admin settings > Transforms > Jobs_
To create a new job, go to _Admin settings > Transforms_, switch to the **Jobs** view, and click **Create a job**
To edit a job, click on the job in the list of jobs.
Jobs have two components: schedule and tags.
Metabase uses tags to locate transforms that should be run by the job, and then schedules those transforms according to the cron schedule. The times are given in the system's timezone.
### Tags
Job can use multiple tags, in which case, the job will run all transforms that have _any_ of those tags. For example, you can have a job "Weekend job" that is scheduled run at noon on Saturdays and Sundays that picks up all transforms tagged either "Saturday", "Sunday", or "Weekend".
By default, Metabase comes with hourly, daily, weekly, and monthly tags and jobs that are run on the corresponding schedules, but you can remove or rename those tags, or create new tags.
To create a new tag, just type the new tag's name in "Tags" field (either when viewing a transform or when viewing a job) and select "Create a tag".
### Jobs take dependencies into account
Depended transforms will be scheduled and run intelligently: if Transform B depends on the output of Transform A, then a job will run Transform A before Transform B. A job will run all dependent transforms even if the dependencies aren't tagged. You will see the order of transform execution on the job's page.
## Transforms vs models
Transforms are similar to models with model persistence turned on, but there are a few crucial differences:
- Transforms can only be created by admins. Models can be created by anyone with permissions to create queries on the data source (but only admins can enable model persistence).
- You can choose the target schema and tables for transforms. Model persistence will create its own schema and tables.
- You can't rename columns in a transform (but you can change column metadata once they're synced back to Metabase). You can rename columns in model metadata and then persist those column names using model persistence.
- Transforms support more databases than model persistence
- On Metabase Cloud with Python execution add-on, you can use Python to create transforms.
Use models to enable non-admins to create their own datasets within Metabase, and to add context like field descriptions and semantic types. Use transforms to create persisted datasets in your database and reuse them across Metabase.

View File

@@ -9,7 +9,6 @@ We recommend creating a `metabase` database user with the following database rol
- [`analytics` for read access](#minimum-database-privileges) to any schemas or tables used for analysis.
- Optional [`metabase_actions` for write access](#privileges-to-enable-actions-and-editable-table-data) to tables used for Metabase actions.
- Optional [`metabase_model_persistence` for write access](#privileges-to-enable-model-persistence) to the schema used for Metabase model persistence.
- Optional [`metabase_transforms` for write access](#privileges-to-enable-transforms) to the schema used for Metabase transforms.
Bundling your privileges into roles based on use cases makes it easier to manage privileges in the future (especially in [multi-tenant situations](#multi-tenant-permissions)). For example, you could:
@@ -135,19 +134,6 @@ GRANT INSERT, UPDATE, DELETE ON "your_model's_table" IN SCHEMA "your_schema" TO
GRANT metabase_model_persistence TO metabase;
```
## Privileges to enable transforms
[Transforms](../data-modeling/transforms.md) let Metabase write query results back to your database. We suggest that you create a dedicated schema for your transforms. Metabase's database user will need to be able to create and drop transform tables.
In addition to the [minimum database privileges](#minimum-database-privileges):
- Create a new role called `metabase_transforms`.
- Create a dedicated schema for transforms.
- Give the `metabase_transforms` role `CREATE TABLE` / `ALTER` / `DROP` privileges for the specified schema
- Give the `metabase_transforms` role to the `metabase` user.
In certain cases, Metabase will give you an option to create new schemas for transforms, in which case you'll also need `CREATE SCHEMA` privileges for your metabase users (or `CREATE DATABASE` for ClickHouse).
## Privileges to enable uploads
You can [upload CSVs](../databases/uploads.md) to supported databases. Metabase's database user should have write access (`INSERT`, `UPDATE`, `DELETE`) to the schema where you want to store the uploads.

View File

@@ -41,6 +41,25 @@ If you're self-hosting Metabase, you can opt out of providing us with your anony
If you're in the process of setting up your Metabase, you can also toggle off tracking during the `Usage Data Preferences` onboarding step. We collect a few anonymous events before that point, but won't do so anymore if you choose to opt out.
## Token validation requests for paid plans
If you're on a paid plan (Pro, Enterprise, or a trial), Metabase periodically sends a token validation request to verify your license. This is separate from the anonymous usage data described above.
### Data included in token validation requests
The token validation request includes:
- Count of active users (used for billing)
- Count of external users
- Count of internal users
- Count of email domains
- Count of embedded dashboards and questions
- Types of embedding (static, interactive, Embedded analtyics SDK, Embedded analytics JS)
- Site UUID (just an identifier for your Metabase)
- Metabase version
- Query execution timestamp (last UTC day)
- Metabot usage statistics: count of tokens, queries, users, and date (just counts and the date).
## Further reading
Check out our page on [data privacy and security](https://www.metabase.com/security).
Check out our page on [data privacy and security](https://www.metabase.com/security).

View File

@@ -50,7 +50,6 @@ Metabase will only export the following entities:
- Collections (but personal collections don't get exported unless explicitly specified them through [export options](#customize-what-gets-exported))
- Dashboards
- Saved questions
- Transforms (including jobs)
- Documents (without comments)
- Actions
- Models

View File

@@ -195,7 +195,7 @@ Learn more about [SQL parameters](../questions/native-editor/sql-parameters.md)
For example, say have a table like this:
| User_ID | Value |
|---------|-------|
| ------- | ----- |
| 1 | 10 |
| 1 | 50 |
| 2 | 5 |
@@ -203,7 +203,7 @@ For example, say have a table like this:
| 3 | 5 |
| 3 | 5 |
If you want to give someone access to multiple user IDs (e.g., the person should see rows for both `User_ID` 1 and 2.), you can set up a user attribute, like `user_id` that can handle comma-separated values like "1,2".
If you want to give someone access to multiple user IDs (e.g., the person should see rows for both `User_ID` 1 and 2.), you can set up a user attribute, like `user_id` that can handle comma-separated values like "1,2".
1. Create a SQL question that parses the comma-separated string and filters the table:
@@ -313,10 +313,6 @@ Create a SQL question that casts the advanced data type column to a basic data t
If you can't use SQL casting in Metabase, create a view in your database that converts the advanced data type to a basic type, then set up row and column security on that view instead of the original table. You'll also need to block the original table.
#### Option 3: Use transforms
Use a [transform](../data-modeling/transforms.md) to create a table that casts the advanced data type to a basic type. Then set up row and column security on the transformed table instead. You'll also need to block the original table.
## Further reading
- [Row and column security examples](./row-and-column-security-examples.md)

View File

@@ -76,6 +76,8 @@ For example, if you want to build a progress bar comparing count of orders this
![Conditional counts as metric and goal](../images/progress-conditional-count.png)
Note that both columns are computed fields here, one for 2024 and one for the current year.
Once you have a column that you want to use, set it as a goal:
1. While viewing the progress bar visualization, click on the **Gear** icon in bottom left to open settings.

View File

@@ -30,9 +30,6 @@ const snowplowMicroUrl = process.env["MB_SNOWPLOW_URL"];
const isQaDatabase = process.env["QA_DB_ENABLED"] === "true";
const sourceVersion = process.env["CROSS_VERSION_SOURCE"];
const targetVersion = process.env["CROSS_VERSION_TARGET"];
const isEmbeddingSdk = process.env.CYPRESS_IS_EMBEDDING_SDK === "true";
// docs say that tsconfig paths should handle aliases, but they don't
@@ -53,7 +50,7 @@ const assetsResolverPlugin = {
};
// these are special and shouldn't be chunked out arbitrarily
const specBlacklist = ["/embedding-sdk/", "/cross-version/"];
const specBlacklist = ["/embedding-sdk/"];
function getSplittableSpecs(specs) {
return specs.filter((spec) => {
@@ -152,8 +149,6 @@ const defaultConfig = {
config.env.IS_ENTERPRISE = isEnterprise;
config.env.HAS_SNOWPLOW_MICRO = hasSnowplowMicro;
config.env.SNOWPLOW_MICRO_URL = snowplowMicroUrl;
config.env.SOURCE_VERSION = sourceVersion;
config.env.TARGET_VERSION = targetVersion;
require("@cypress/grep/src/plugin")(config);
@@ -238,18 +233,6 @@ const snapshotsConfig = {
video: false,
};
const crossVersionSourceConfig = {
...defaultConfig,
baseUrl: "http://localhost:3000",
specPattern: "e2e/test/scenarios/cross-version/source/**/*.cy.spec.{js,ts}",
};
const crossVersionTargetConfig = {
...defaultConfig,
baseUrl: "http://localhost:3001",
specPattern: "e2e/test/scenarios/cross-version/target/**/*.cy.spec.{js,ts}",
};
const stressTestConfig = {
...defaultConfig,
retries: 0,
@@ -279,7 +262,5 @@ module.exports = {
mainConfig,
snapshotsConfig,
stressTestConfig,
crossVersionSourceConfig,
crossVersionTargetConfig,
embeddingSdkComponentTestConfig,
};

View File

@@ -94,6 +94,10 @@ export function downloadAndAssert(
cy.log(`Downloading ${fileType} file`);
// For _some_ reason wait is necessary for dashcard menu to stay open after
// the click. I assume card needs to finish loading.
cy.wait(200);
if (isDashboard) {
if (isEmbed) {
getDashboardCard().realHover();

View File

@@ -19,19 +19,3 @@ export const createSegment = ({
definition,
});
};
export const createMetric = ({
name,
table_id,
definition,
description = null,
}) => {
cy.log(`Create a metric: ${name}`);
// This endpoint doesn't exist anymore, but we need this helper for cross-version tests
return cy.request("POST", "/api/metric", {
name,
description,
table_id,
definition,
});
};

View File

@@ -254,6 +254,10 @@ export const queryBuilderFooter = () => {
return cy.findByTestId("view-footer");
};
export const queryBuilderFooterDisplayToggle = () => {
return cy.findByTestId("query-display-tabular-toggle");
};
export const closeQuestionActions = () => {
queryBuilderHeader().click();
};

View File

@@ -292,3 +292,40 @@ describe("scenarios > embedding-sdk > interactive-dashboard", () => {
cy.get("@datasetQuery.all").should("have.length", 1);
});
});
describe("scenarios > embedding-sdk > interactive-dashboard > tabs", () => {
it("should not show add a chart button on empty dashboard with many tabs (metabase#65001)", () => {
signInAsAdminAndEnableEmbeddingSdk();
const TAB_WITH_CARDS = { id: 1, name: "Tab with cards" };
const EMPTY_TAB = { id: 2, name: "Empty tab" };
const questionCard: Partial<DashboardCard> = {
id: ORDERS_DASHBOARD_DASHCARD_ID,
dashboard_tab_id: TAB_WITH_CARDS.id,
card_id: ORDERS_QUESTION_ID,
row: 0,
col: 0,
size_x: 16,
size_y: 8,
};
H.createDashboardWithTabs({
name: "Dashboard with empty tab",
tabs: [TAB_WITH_CARDS, EMPTY_TAB],
dashcards: [questionCard],
}).then(({ id: dashboardId }) => {
cy.signOut();
mockAuthProviderAndJwtSignIn();
mountSdkContent(<InteractiveDashboard dashboardId={dashboardId} />);
});
getSdkRoot().within(() => {
cy.findByText("Orders").should("be.visible");
cy.findByRole("tab", { name: EMPTY_TAB.name }).click();
cy.findByTestId("dashboard-empty-state").should("be.visible");
cy.findByText("Add a chart").should("not.exist");
});
});
});

View File

@@ -768,7 +768,6 @@ describe("scenarios > content translation > static embedding > dashboards", () =
H.filterWidget().findByText("Multi").click();
// Search matches against untranslated text, hence "Fran" matching these names
cy.findByPlaceholderText("Recherche dans la liste").type("Fran");
cy.wait("@searchQuery");
cy.findByTestId("parameter-value-dropdown").within(() => {
cy.findByText(/Glacia Froskeon/).click();
cy.button(/Ajouter un filtre/).click();

View File

@@ -105,7 +105,7 @@ describe("Remote Sync", () => {
cy.wait("@updateDashboard").then((req) => {
expect(req.response?.statusCode).to.eq(400);
expect(req.response?.body.message).to.contain(
"non-remote-synced dependencies",
"content that is not remote synced",
);
});

View File

@@ -533,6 +533,7 @@ describe("scenarios > collection defaults", () => {
({ body: { groups, revision } }) => {
// Give `Data` group permission to "curate" Child collection only
// Access to everything else is revoked by default - that's why we chose `Data` group
groups[DATA_GROUP] = {};
groups[DATA_GROUP][CHILD_COLLECTION_ID] = "write";
// We're chaining these 2 requestes in order to match schema (passing it from GET to PUT)

View File

@@ -1,186 +0,0 @@
export function parseVersionString(versionString) {
if (typeof versionString === "undefined") {
return []; // Return empty array if versionString is undefined
}
const segments = versionString.match(/\d+/g);
if (!segments) {
return { version: versionString }; // Return versionString if no segments found
}
const segmentInts = segments.map((segment) => parseInt(segment, 10));
const editionIndex = segmentInts[0];
let edition = null;
if (editionIndex === 1) {
edition = "ee";
} else if (editionIndex === 0) {
edition = "oss";
}
return {
version: versionString,
edition: edition,
majorVersion: segmentInts[1],
minorVersion: segmentInts[2],
patchVersion: segmentInts[3],
};
}
// Versions in which a breaking GUI change was introduced. For example, version
// 32 added a "What will you use Metabase for?" stage in the initial setup.
// Change: Makes the "Sample Database" in setup more prominent
// GitHub PR: https://github.com/metabase/metabase/pull/60511
// Date: 2025-07-08
// Author: nemanjaglumac
const moreProminentSampleDbInSetupVersion = 56;
// Change: Adds "What will you use Metabase for?" stage in the initial setup.
// Git sha: d88d32e5e021ad4f47b5d740b78df73945e8ff82
// Date: 2024-02-08
// Author: npretto
const metabasePurposeVersion = 49;
// Change: Question save modal logic changes required different cypress logic
// to click "Save" button as former cy.clck("Save") became ambiguous.
// Git sha: f90e4db22e0b668600846e00ed4d1e28d8f92d95
// Date: 2024-02-23
// Author: markbastian
const updatedCypressSaveLogicVersion = 49;
// Change: Sample data times changed.
// TODO: The specific version in which this changed hasn't been tracked down
// yet, but this is an upper bound. If more cross-version checks are added for
// earlier versions and this breaks, this number may need to be adjusted down.
export const sampleDataTimesChangedVersion = 47;
// Change: In older versions of metabase, when a question was loaded a modal was
// presented stating "It's okay to play around with saved questions". The user
// was required to press "Okay" to dismiss this modal. In later versions this
// modal was not presented in the same location.
// TODO: The specific version in which this changed hasn't been tracked down
// yet, but this is an upper bound. If more cross-version checks are added for
// earlier versions and this breaks, this number may need to be adjusted down.
export const questionsAreOkToPlayWithModalVersion = 45;
// Change: Older versions of Metabase presented a 3-panel display, the center
// panel being "Custom question". In later generations, this was accessible via
// clicking "New" -> "Question" at the top right corner in the UI.
// TODO: The specific version in which this changed hasn't been tracked down
// yet, but this is an upper bound. If more cross-version checks are added for
// earlier versions and this breaks, this number may need to be adjusted down.
export const newQuestionMenuVersion = 45;
// Change: In older versions of metabase, area and line charts were selectable
// Visualization types, but the actual operation to fill the area was to select
// the "area" icon in the Display settings, which is really weird.
// TODO: The specific version in which this changed hasn't been tracked down
// yet, but this is an upper bound. If more cross-version checks are added for
// earlier versions and this breaks, this number may need to be adjusted down.
export const filledAreaIconRemovedVersion = 45;
export function setupLanguage() {
// Make sure English is the default selected language
cy.findByText("English")
.should("have.css", "background-color")
.and("eq", "rgb(80, 158, 227)");
cy.button("Next").click();
cy.findByText("Your language is set to English");
}
export function setupInstance({ version, majorVersion }) {
const companyLabel =
version === "v0.41.3.1"
? "Your company or team name"
: "Company or team name";
const finalSetupButton = version === "v0.41.3.1" ? "Next" : "Finish";
cy.findByLabelText("First name").type("Superuser");
cy.findByLabelText("Last name").type("Tableton");
cy.findByLabelText("Email").type("admin@metabase.test");
cy.findByLabelText(companyLabel).type("Metabase");
cy.findByLabelText("Create a password").type("12341234");
cy.findByLabelText("Confirm your password").type("12341234");
cy.button("Next").click();
cy.findByText("Hi, Superuser. Nice to meet you!");
// A "What will you use Metabase for?" prompt exists in later versions.
// If it exists, click through.
if (majorVersion >= metabasePurposeVersion) {
cy.button("Next").click();
}
const continueWithoutAddingDatabaseCopy =
majorVersion >= moreProminentSampleDbInSetupVersion
? "Continue with sample data"
: "I'll add my data later";
cy.findByText(continueWithoutAddingDatabaseCopy).click();
cy.findByText("I'll add my own data later");
// Collection defaults to on and describes data collection
cy.findByText("All collection is completely anonymous.");
// turn collection off, which hides data collection description
cy.findByLabelText(
"Allow Metabase to anonymously collect usage events",
).click();
cy.findByText("All collection is completely anonymous.").should("not.exist");
cy.findByText(finalSetupButton).click();
cy.findByText("Take me to Metabase").click();
cy.location("pathname").should("eq", "/");
cy.contains("Reviews");
}
export function newQuestion({ majorVersion }) {
if (majorVersion < newQuestionMenuVersion) {
cy.findByText("Custom question").click();
} else {
cy.button("New").click();
cy.findByText("Question").click();
}
}
export function saveQuestion({ majorVersion }) {
if (majorVersion < updatedCypressSaveLogicVersion) {
cy.button("Save").click();
} else {
cy.findByTestId("save-question-modal").within((modal) => {
cy.findByText("Save").click();
});
}
}
export function assertTimelineData({ majorVersion }) {
if (majorVersion < sampleDataTimesChangedVersion) {
cy.get(".x.axis .tick")
.should("contain", "Q1 - 2017")
.and("contain", "Q1 - 2018")
.and("contain", "Q1 - 2019")
.and("contain", "Q1 - 2020");
} else {
cy.get(".x.axis .tick")
.should("contain", "Q1 2023")
.and("contain", "Q1 2024")
.and("contain", "Q1 2025")
.and("contain", "Q1 2026");
}
}
export function dismissOkToPlayWithQuestionsModal({ majorVersion }) {
if (majorVersion < questionsAreOkToPlayWithModalVersion) {
cy.findByText("It's okay to play around with saved questions");
cy.button("Okay").click();
}
}
export function fillAreaUnderLineChart({ majorVersion }) {
if (majorVersion < filledAreaIconRemovedVersion) {
cy.findByTestId("sidebar-left").within((element) => {
cy.icon("area").click();
});
}
}

View File

@@ -1,26 +0,0 @@
import {
setupInstance,
setupLanguage,
} from "e2e/test/scenarios/cross-version/helpers/cross-version-helpers.js";
import { version } from "./helpers/cross-version-source-helpers";
describe(`setup on ${version}`, () => {
it("should set up metabase", () => {
cy.visit("/");
// It redirects to the setup page
cy.location("pathname").should("eq", "/setup");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Welcome to Metabase");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Let's get started").click();
setupLanguage(version);
setupInstance(version);
cy.visit("/admin");
// Find an element on the admin page so we know we've landed.
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Setup");
});
});

View File

@@ -1,9 +0,0 @@
const { H } = cy;
it("should generate metadata", () => {
cy.signInAsAdmin();
H.withSampleDatabase((SAMPLE_DATABASE) => {
cy.writeFile("e2e/support/cypress_sample_database.json", SAMPLE_DATABASE);
});
});

View File

@@ -1,158 +0,0 @@
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
createMetric,
createSegment,
} from "e2e/support/helpers/e2e-table-metadata-helpers";
const { ORDERS, ORDERS_ID, REVIEWS, PRODUCTS, PEOPLE } = SAMPLE_DATABASE;
const sampleDBDataModelPage = `/admin/datamodel/database/${SAMPLE_DB_ID}`;
it("should configure data model settings", () => {
cy.intercept("GET", "/api/segment").as("getSegments");
cy.intercept("GET", "/api/metric").as("getMetrics");
cy.signInAsAdmin();
cy.visit("/admin/datamodel");
cy.location("pathname").should("eq", sampleDBDataModelPage);
// Remap ORDERS.PRODUCT_ID display value to PRODUCTS.TITLE
cy.intercept("POST", `/api/field/${ORDERS.PRODUCT_ID}/dimension`).as(
"updateProductId",
);
cy.get(".AdminList").findByText("Orders").click();
cy.findByDisplayValue("Product ID")
.parent()
.parent()
.find(".Icon-gear")
.click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Use original value").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Use foreign key").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Title").click();
cy.wait("@updateProductId");
cy.visit(sampleDBDataModelPage);
cy.get(".AdminList").findByText("Reviews").click();
cy.intercept("POST", `/api/field/${REVIEWS.RATING}/values`).as(
"remapRatingValues",
);
cy.findByDisplayValue("Rating").parent().parent().find(".Icon-gear").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Use original value").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Custom mapping").click();
cy.wait("@remapRatingValues");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText(
"You might want to update the field name to make sure it still makes sense based on your remapping choices.",
);
const customMap = {
1: "Awful",
2: "Unpleasant",
3: "Meh",
4: "Enjoyable",
5: "Perfecto",
};
Object.entries(customMap).forEach(([key, value]) => {
cy.findByDisplayValue(key).click().clear().type(value, { delay: 0 });
});
cy.button("Save").click();
cy.wait("@remapRatingValues");
// Hide PRODUCTS.EAN
cy.visit(sampleDBDataModelPage);
cy.get(".AdminList").findByText("Products").click();
cy.intercept("PUT", `/api/field/${PRODUCTS.EAN}`).as("hideEan");
cy.findByDisplayValue("Ean").parent().parent().contains("Everywhere").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Do not include").click();
cy.wait("@hideEan");
cy.intercept("PUT", `/api/field/${PRODUCTS.PRICE}`).as("updatePriceField");
cy.findByDisplayValue("Price")
.parent()
.parent()
.findByText("No semantic type")
.click();
cy.get(".MB-Select").should("be.visible");
cy.get(".MB-Select")
.scrollTo("top")
.within(() => {
cy.findByPlaceholderText("Find...").type("Pr");
cy.findByText("Price").click();
});
cy.wait("@updatePriceField");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("US Dollar").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Euro").click();
cy.wait("@updatePriceField");
// Hide PEOPLE.PASSWORD
cy.get(".AdminList").findByText("People").click();
cy.intercept("PUT", `/api/field/${PEOPLE.PASSWORD}`).as("hidePassword");
cy.findByDisplayValue("Password")
.parent()
.parent()
.contains("Everywhere")
.click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Do not include").click();
cy.wait("@hidePassword");
const metric = {
name: "Revenue",
description: "Sum of orders subtotal",
table_id: ORDERS_ID, // legacy api
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.SUBTOTAL, null]]],
},
};
const segment = {
name: "Large Purchases",
description: "Orders over $100.",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
filter: [">", ["field", ORDERS.TOTAL, null], 100],
},
};
createMetric(metric); // legacy api
createSegment(segment);
cy.visit("/admin/datamodel/segments");
cy.wait("@getSegments");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText(segment.name);
cy.visit("/admin/datamodel/metrics");
cy.wait("@getMetrics");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText(metric.name);
});

View File

@@ -1,136 +0,0 @@
const { H } = cy;
import {
fillAreaUnderLineChart,
newQuestion,
saveQuestion,
} from "e2e/test/scenarios/cross-version/helpers/cross-version-helpers.js";
import { version } from "./helpers/cross-version-source-helpers";
it("should create questions", () => {
cy.signInAsAdmin();
cy.visit("/question/new");
newQuestion(version);
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Orders").click();
cy.icon("join_left_outer").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Products").click();
// Make sure `on` condition is populated automatically
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Product ID").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Add filters to narrow your answer").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Large Purchases").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Pick the metric you want to see").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText(/^Average of/).click();
cy.findByRole("heading", { name: /Products?/ }).click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Rating").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Pick a column to group by").click();
// Older versions were Products, newer use Product
cy.get(".List-section-title")
.contains(/Products?/)
.click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Category").click();
H.visualize();
cy.get(".bar").should("have.length", 4);
cy.findByTestId("viz-settings-button").click();
//NOTE: In older versions of Metabase, Display is selected by default. Newer
// versions default to Data. This will ensure we've selected the right tab
// either way.
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Display").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.contains("Show values on data points").next().click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.contains("3.71");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Save").click();
cy.findByLabelText("Name").clear().type("Rating of Best-selling Products");
cy.findByLabelText("Description").type(
"The average rating of our top selling products broken down into categories.",
{ delay: 0 },
);
saveQuestion(version);
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Not now").click();
cy.visit("/question/new");
newQuestion(version);
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText(/Sample (Dataset|Database)/).click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Orders").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Pick the metric you want to see").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Common Metrics").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Revenue").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Pick a column to group by").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Created At")
.closest(".List-item")
.findByText("by month")
.click({ force: true });
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Quarter").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Created At: Quarter");
H.visualize();
cy.get("circle");
cy.findByTestId("viz-type-button").click();
cy.findByTestId("Area-button").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Display").click();
fillAreaUnderLineChart(version);
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Goal line").next().click();
cy.findByDisplayValue("0").type("100000").blur();
cy.get(".line");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Goal");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Save").click();
cy.findByLabelText("Name").clear().type("Quarterly Revenue");
saveQuestion(version);
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Not now").click();
});

View File

@@ -1,3 +0,0 @@
import { parseVersionString } from "e2e/test/scenarios/cross-version/helpers/cross-version-helpers.js";
export const version = parseVersionString(Cypress.env("SOURCE_VERSION"));

View File

@@ -1,5 +0,0 @@
const { defineConfig } = require("cypress");
const { crossVersionSourceConfig } = require("e2e/support/config");
module.exports = defineConfig({ e2e: crossVersionSourceConfig });

View File

@@ -1,3 +0,0 @@
import { parseVersionString } from "e2e/test/scenarios/cross-version/helpers/cross-version-helpers.js";
export const version = parseVersionString(Cypress.env("TARGET_VERSION"));

View File

@@ -1,5 +0,0 @@
const { defineConfig } = require("cypress");
const { crossVersionTargetConfig } = require("e2e/support/config");
module.exports = defineConfig({ e2e: crossVersionTargetConfig });

View File

@@ -1,67 +0,0 @@
import {
assertTimelineData,
dismissOkToPlayWithQuestionsModal,
} from "e2e/test/scenarios/cross-version/helpers/cross-version-helpers";
import { version } from "e2e/test/scenarios/cross-version/source/helpers/cross-version-source-helpers.js";
describe(`smoke test the migration to the version ${version}`, () => {
it("should already be set up", () => {
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
cy.visit("/");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Sign in to Metabase");
cy.findByLabelText("Email address").type("admin@metabase.test");
cy.findByLabelText("Password").type("12341234");
cy.button("Sign in").click();
cy.findByPlaceholderText("Search…");
// Question 1
cy.visit("/collection/root");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Quarterly Revenue").click();
cy.wait("@cardQuery");
dismissOkToPlayWithQuestionsModal(version);
cy.get("circle");
cy.get(".line");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Goal");
cy.get(".x-axis-label").invoke("text").should("eq", "Created At");
cy.get(".y-axis-label").invoke("text").should("eq", "Revenue");
assertTimelineData(version);
cy.get(".y.axis .tick")
.should("contain", "20,000")
.and("contain", "100,000")
.and("contain", "140,000");
// Question 2
cy.visit("/collection/root");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Rating of Best-selling Products").click();
cy.wait("@cardQuery");
cy.get(".bar").should("have.length", 4);
cy.get(".x.axis .tick")
.should("contain", "Gizmo")
.and("contain", "Gadget")
.and("contain", "Doohickey")
.and("contain", "Widget");
cy.get(".value-labels")
.should("contain", "3.27")
.and("contain", "3.3")
.and("contain", "3.71")
.and("contain", "3.4");
cy.get(".x-axis-label").invoke("text").should("eq", "Products → Category");
cy.get(".y-axis-label")
.invoke("text")
.should("eq", "Average of Products → Rating");
});
});

View File

@@ -1630,13 +1630,13 @@ describe("issue 47170", () => {
});
it("should show legible dark mode colors in fullscreen mode (metabase#51524)", () => {
cy.visit("/account/profile");
cy.findByDisplayValue("Light").click();
H.popover().findByText("Dark").click();
cy.visit(`/dashboard/${ORDERS_DASHBOARD_ID}`);
H.dashboardHeader().findByLabelText("Move, trash, and more…").click();
H.popover().findByText("Enter fullscreen").click();
H.dashboardHeader()
.findByLabelText(/dark mode/)
.click();
const primaryTextColor = "rgba(255, 255, 255, 0.95)";

View File

@@ -430,6 +430,19 @@ describe("documents card embed node custom logic", () => {
});
describe("deleting a cardEmbed", () => {
it("should allow you to remove a card if it is the first item in a docuemnt (UXW-2169)", () => {
cy.visit("/document/new");
H.documentContent().click();
H.addToDocument("/ord", false);
H.commandSuggestionItem(/Orders, Count$/).click();
H.openDocumentCardMenu("Orders, Count");
H.popover().findByText("Remove Chart").click();
cy.findAllByTestId("document-card-embed").should("have.length", 0);
});
it("should delete a cardEmbed when selected and Backspace is pressed", () => {
H.createDocument({
name: "DnD Test Document",

View File

@@ -0,0 +1,110 @@
const { H } = cy;
import { USER_GROUPS } from "e2e/support/cypress_data";
const { ALL_USERS_GROUP } = USER_GROUPS;
H.describeWithSnowplowEE("document permissions", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
H.resetSnowplow();
cy.signOut();
});
it("should allow a non-admin user to create a new document and save it", () => {
cy.signInAsAdmin();
cy.updateCollectionGraph({
[ALL_USERS_GROUP]: { root: "none" },
});
cy.signOut();
cy.signIn("none");
cy.visit("/");
H.newButton("Document").click();
cy.title().should("eq", "New document · Metabase");
cy.findByRole("textbox", { name: "Document Title" })
.should("be.focused")
.type("User Document");
H.documentContent().type("This is a document created by a non-admin user");
cy.findByRole("button", { name: "Save" }).click();
cy.findByTestId("item-picker-level-0")
.findByText("Our analytics")
.should("not.exist");
H.entityPickerModalItem(0, "Collections").should("exist");
H.entityPickerModalItem(0, /Personal Collection/).click();
H.entityPickerModal().findByRole("button", { name: "Select" }).click();
cy.location("pathname").should("match", /^\/document\/\d+/);
cy.title().should("eq", "User Document · Metabase");
H.expectUnstructuredSnowplowEvent({ event: "document_created" });
H.appBar()
.findByRole("link", { name: /Personal Collection/ })
.click();
H.collectionTable()
.findByRole("link", { name: "User Document" })
.should("exist");
});
it("should allow a non-admin user to edit their own document", () => {
cy.signInAsNormalUser();
H.createDocument({
name: "User Document",
document: {
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Original content",
},
],
attrs: {
_id: "1",
},
},
],
type: "doc",
},
collection_id: null,
alias: "document",
idAlias: "documentId",
});
H.visitDocument("@documentId");
H.documentContent().should("contain.text", "Original content");
H.documentContent()
.findByRole("textbox")
.should("have.attr", "contenteditable", "true");
H.documentContent().click();
H.addToDocument(" and some new content");
H.documentSaveButton().click();
cy.findByTestId("toast-undo")
.findByText("Document saved")
.should("be.visible");
H.documentContent().should(
"contain.text",
"Original content and some new content",
);
});
});

View File

@@ -145,7 +145,15 @@ H.describeWithSnowplowEE("documents", () => {
// Force the click since this is hidden behind a toast notification
H.navigationSidebar().findByText("Trash").click({ force: true });
H.getUnpinnedSection().findByText("Test Document").should("exist");
H.getUnpinnedSection().findByText("Test Document").should("exist").click();
cy.log("test that deleted documents cannot be edited (metabase#63112)");
cy.findByRole("textbox", { name: "Document Title" })
.should("be.visible")
.and("have.attr", "readonly");
H.documentContent()
.findByRole("textbox")
.should("have.attr", "contenteditable", "false");
});
it("should handle navigating from /new to /new gracefully", () => {
@@ -706,6 +714,31 @@ H.describeWithSnowplowEE("documents", () => {
H.getDocumentCard("Orders").should("exist");
});
it("should support renaming cards", () => {
cy.log("Add card");
H.documentContent().click();
H.addToDocument("/", false);
H.commandSuggestionItem("Chart").click();
H.commandSuggestionDialog()
.findByText(PRODUCTS_COUNT_BY_CATEGORY_PIE.name)
.click();
cy.log("Rename card");
cy.findByTestId("card-embed-title").realHover();
cy.icon("pencil").click();
cy.realType("New name{enter}");
cy.log("Edit query");
H.openDocumentCardMenu("New name");
H.popover().findByText("Edit Query").click();
H.removeSummaryGroupingField({ field: "Category" });
H.addSummaryGroupingField({ field: "Price" });
H.modal().findByRole("button", { name: "Save and use" }).click();
cy.log("Assert new name is preserved");
H.getDocumentCard("New name").should("exist");
});
it("should support resizing cards", () => {
H.documentContent().click();
H.addToDocument("/", false);

View File

@@ -87,15 +87,19 @@ describe("scenarios > models list view", () => {
cy.get("@listPreview").should("be.visible");
cy.get("@listPreview").within(() => {
// Verify preview shows sample data values
cy.findByRole("img").should("have.attr", "aria-label", "document icon");
cy.findAllByRole("img").should(
"have.attr",
"aria-label",
"document icon",
);
cy.findAllByText("1").should("have.length", 2);
cy.findByText("14").should("be.visible");
cy.findByText("37.65").should("be.visible");
cy.findByText("2.07").should("be.visible");
});
cy.log("Add CREATED_AT column to left columns");
cy.get("@leftColumns").within(() => {
cy.log("Add CREATED_AT column to right columns");
cy.get("@rightColumns").within(() => {
cy.get("input").type("CR");
});
@@ -131,20 +135,36 @@ describe("scenarios > models list view", () => {
cy.findByText("DISCOUNT").click();
});
cy.get("@listPreview").within(() => {
cy.findByText("$123.46").should("be.visible");
cy.findByText("123.46").should("be.visible");
});
cy.log("Update list item icon");
cy.get("@listPreview").within(() => {
cy.findByRole("img").should("have.attr", "aria-label", "document icon");
cy.findAllByRole("img").should(
"have.attr",
"aria-label",
"document icon",
);
});
cy.findByTestId("list-view-icon").click();
H.popover().within(() => {
cy.findByText("Company").should("be.visible").click();
cy.findByRole("img", { name: "factory icon" })
.should("have.attr", "aria-label", "factory icon")
.click();
cy.findByTestId("list-view-icon-colors").then(($list) => {
const $button = Cypress.$($list).find("button").eq(2);
expect($button.attr("style")).to.include(
"background: var(--mb-color-accent1)",
);
$button.click();
});
});
cy.get("@listPreview").within(() => {
cy.findByRole("img").should("have.attr", "aria-label", "company icon");
cy.findAllByRole("img")
.first()
.should("have.attr", "style", "color: var(--mb-color-accent1);")
.and("have.attr", "aria-label", "factory icon");
});
cy.findByTestId("dataset-edit-bar").button("Save changes").click();
@@ -152,7 +172,7 @@ describe("scenarios > models list view", () => {
cy.log("Verify that custom column set is correct");
cy.findByTestId("visualization-root").within(() => {
cy.findByText("ID and CREATED_AT").should("be.visible");
cy.findByText("ID").should("be.visible");
cy.findByText("USER_ID").should("be.visible");
cy.findByText("PRODUCT_ID").should("be.visible");
cy.findByText("SUBTOTAL").should("be.visible");
@@ -160,7 +180,7 @@ describe("scenarios > models list view", () => {
cy.findAllByRole("img")
.first()
.should("have.attr", "aria-label", "company icon");
.should("have.attr", "aria-label", "factory icon");
cy.findByText("February 11, 2025, 9:40 PM").should("be.visible");
cy.findByText("14").should("be.visible");
cy.findByText("37.65").should("be.visible");

View File

@@ -1548,3 +1548,52 @@ describe("issue 63745", () => {
});
});
});
describe("issue 56094", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("should allow to switch between automatic pivot table and usual table visualization (metabase#56094)", () => {
H.visitQuestionAdhoc({
name: "56094",
dataset_query: {
type: "query",
database: SAMPLE_DB_ID,
query: {
"source-table": PRODUCTS_ID,
aggregation: [["count"]],
breakout: [
[
"field",
PRODUCTS.CATEGORY,
{
"base-type": "type/Text",
},
],
[
"field",
PRODUCTS.RATING,
{
binning: {
strategy: "default",
},
},
],
],
limit: 20,
},
},
});
H.queryBuilderFooter().findByLabelText("Switch to data").click();
H.queryBuilderFooterDisplayToggle().should("exist");
H.queryBuilderFooter().findByLabelText("Switch to visualization").click();
H.queryBuilderFooterDisplayToggle().should("exist");
});
});

View File

@@ -44,7 +44,7 @@
(let [add-on {:product-type product-type}]
(events/publish-event! :event/cloud-add-on-purchase {:details {:add-on add-on}, :user-id api/*current-user-id*})
(hm.client/call :change-add-ons :upsert-add-ons [add-on]))
(premium-features/clear-cache)
(premium-features/clear-cache!)
{:status 200 :body {}}
(catch Exception e
(log/warnf e "Error adding purchasing add-on '%s'" product-type)

View File

@@ -8,12 +8,14 @@
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.collections.models.collection :as collection]
[metabase.collections.models.collection.root :as collection.root]
[metabase.graph.core :as graph]
[metabase.lib-be.core :as lib-be]
[metabase.lib.metadata :as lib.metadata]
[metabase.models.interface :as mi]
[metabase.native-query-snippets.core :as native-query-snippets]
[metabase.permissions.core :as perms]
[metabase.queries.schema :as queries.schema]
[metabase.revisions.core :as revisions]
[metabase.util.i18n :refer [tru]]
@@ -135,68 +137,161 @@
(broken-cards-response breakages)))
(def ^:private entity-keys
{:table [:name :description :display_name :db_id :db :schema :fields]
:card [:name :type :display :database_id :view_count
:created_at :creator :creator_id :description
:result_metadata :last-edit-info
:collection :collection_id :dashboard :dashboard_id
:moderation_reviews]
:snippet [:name :description]
{:table [:name :description :display_name :db_id :db :schema :fields]
:card [:name :type :display :database_id :view_count
:created_at :creator :creator_id :description
:result_metadata :last-edit-info
:collection :collection_id :dashboard :dashboard_id
:moderation_reviews]
:snippet [:name :description]
:transform [:name :description :table]
:dashboard [:name :description :view_count
:created_at :creator :creator_id :last-edit-info
:collection :collection_id
:moderation_reviews]
:document [:name :description :view_count
:created_at :creator
:collection :collection_id]
:sandbox [:table :table_id]})
:document [:name :description :view_count
:created_at :creator
:collection :collection_id]
:sandbox [:table :table_id]})
(defn- format-subentity [entity]
(case (t2/model entity)
:model/Collection (select-keys entity [:id :name :authority_level :is_personal])
:model/Dashboard (select-keys entity [:id :name])
:model/Dashboard (select-keys entity [:id :name])
entity))
(defn- entity-value [entity-type {:keys [id] :as entity} usages]
{:id id
:type entity-type
:data (->> (select-keys entity (entity-keys entity-type))
(m/map-vals format-subentity))
{:id id
:type entity-type
:data (->> (select-keys entity (entity-keys entity-type))
(m/map-vals format-subentity))
:dependents_count (usages [entity-type id])})
(def ^:private entity-model
{:table :model/Table
:card :model/Card
:snippet :model/NativeQuerySnippet
{:table :model/Table
:card :model/Card
:snippet :model/NativeQuerySnippet
:transform :model/Transform
:dashboard :model/Dashboard
:document :model/Document
:sandbox :model/Sandbox})
:document :model/Document
:sandbox :model/Sandbox})
(defn- readable-node?
"Check if the user can read the entity represented by `[entity-type entity-id]`."
[[entity-type entity-id]]
(if-let [model (entity-model entity-type)]
(try
(mi/can-read? model entity-id)
(catch Exception e
(log/debugf e "Couldn't determine if %s with id %s is readble"
(some-> entity-type name) entity-id)
false))
(do
(log/warn "Cannot determine the model of" (some-> entity-type name))
false)))
;; IMPORTANT: This map defines which fields to select when fetching entities for the dependency graph.
;; These field lists MUST be kept in sync with the frontend type definitions in:
;; frontend/src/metabase-types/api/dependencies.ts
;; (See CardDependencyNodeData, DashboardDependencyNodeData, etc.)
;;
;; Note: Some fields (like :creator, :collection, :moderation_reviews) are added via t2/hydrate,
;; and others (like :last-edit-info, :view_count) are computed/added separately.
;; This map only lists the base database columns to SELECT.
(def ^:private entity-select-fields
{:card [:id :name :description :type :display :database_id :collection_id :dashboard_id :result_metadata
:created_at :creator_id
;; :card_schema always has to be selected
:card_schema]
:dashboard [:id :name :description :created_at :creator_id :collection_id]
:document [:id :name :created_at :creator_id :collection_id]
:table [:id :name :description :display_name :db_id :schema]
:transform [:id :name :description]
:snippet [:id :name :description]
:sandbox [:id :table_id]})
(defn- visible-entities-filter-clause
"Returns a HoneySQL WHERE clause for filtering dependency graph entities by user visibility.
Accepts three arguments:
- `entity-type-field`: Database column name for entity type (e.g., :to_entity_type)
- `entity-id-field`: Database column name for entity ID (e.g., :to_entity_id)
- `include-archived-items`: How to handle archived items (:exclude, :all, :only). Defaults to :exclude.
This applies to both archived collections AND archived entities (cards/dashboards/documents/snippets/tables).
Returns a compound [:or ...] clause checking whether entities at those columns are readable.
Handles different entity types:
- Superuser-only (:model/Transform, :model/Sandbox): Only if api/*is-superuser?* is true
- Collection-based (:model/Card, :model/Dashboard, :model/Document, :model/NativeQuerySnippet):
Uses collection/visible-collection-filter-clause for collection filtering and adds archived entity filtering.
Native query snippets have additional restrictions for sandboxed users.
- Table: Uses mi/visible-filter-clause with appropriate permissions and filters by active/visibility_type.
Follows search API conventions: active=true AND visibility_type=nil for non-archived tables.
Note: archived_at is not checked separately as archived tables always have active=false."
([entity-type-field entity-id-field]
(visible-entities-filter-clause entity-type-field entity-id-field nil))
([entity-type-field entity-id-field {:keys [include-archived-items] :or {include-archived-items :exclude}}]
(into [:or]
(keep (fn [[entity-type model]]
(let [table-name (t2/table-name model)
id-column (keyword (name table-name) "id")]
(case model
;; Superuser-only entities
(:model/Transform :model/Sandbox)
(when api/*is-superuser?*
[:and
[:= entity-type-field (name entity-type)]
[:in entity-id-field {:select [:id] :from [table-name]}]])
;; Collection-based entities with archived field
(:model/Card :model/Dashboard :model/Document :model/NativeQuerySnippet)
(let [archived-column (keyword (name table-name) "archived")]
(when-not (and (= model :model/NativeQuerySnippet)
(or (perms/sandboxed-user?)
(not (perms/user-has-any-perms-of-type?
api/*current-user-id* :perms/create-queries))))
[:and
[:= entity-type-field (name entity-type)]
[:in entity-id-field {:select [:id]
:from [table-name]
:where [:and
;; Filter by collection visibility
(collection/visible-collection-filter-clause
(keyword (name table-name) "collection_id")
{:include-archived-items include-archived-items}
{:current-user-id api/*current-user-id*
:is-superuser? api/*is-superuser?*})
;; Filter by entity archived status
(case include-archived-items
:exclude [:= archived-column false]
:only [:= archived-column true]
:all nil)]}]]))
;; Table with visible-filter-clause and active/visibility_type filtering
:model/Table
(let [active-column (keyword (name table-name) "active")
visibility-type-column (keyword (name table-name) "visibility_type")]
[:and
[:= entity-type-field (name entity-type)]
[:in entity-id-field {:select [:id]
:from [table-name]
:where [:and
(mi/visible-filter-clause
model
id-column
{:user-id api/*current-user-id*
:is-superuser? api/*is-superuser?*}
{:perms/view-data :unrestricted
:perms/create-queries :query-builder})
(case include-archived-items
:exclude [:and
[:= active-column true]
[:= visibility-type-column nil]]
(:only :all) nil)]}]])))))
entity-model)))
(defn- readable-graph-dependencies
"Return a permission-aware dependency graph that only includes readable entities."
[]
(dependency/filtered-graph-dependencies readable-node?))
([]
(readable-graph-dependencies nil))
([opts]
(dependency/filtered-graph-dependencies
(fn [entity-type-field entity-id-field]
(visible-entities-filter-clause entity-type-field entity-id-field opts)))))
(defn- readable-graph-dependents
"Return a permission-aware dependent graph that only includes readable entities."
[]
(dependency/filtered-graph-dependents readable-node?))
([]
(readable-graph-dependents nil))
([opts]
(dependency/filtered-graph-dependents
(fn [entity-type-field entity-id-field]
(visible-entities-filter-clause entity-type-field entity-id-field opts)))))
(defn- calc-usages
"Calculates the count of direct dependents for all nodes in `nodes`, based on `graph`. "
@@ -224,41 +319,49 @@
nodes-by-type (->> (group-by first nodes)
(m/map-vals #(map second %)))]
(mapcat (fn [[entity-type entity-ids]]
(->> (cond-> (t2/select (entity-model entity-type)
:id [:in entity-ids])
(= entity-type :card) (-> (t2/hydrate :creator :dashboard [:collection :is_personal] :moderation_reviews)
(->> (map collection.root/hydrate-root-collection))
(revisions/with-last-edit-info :card))
(= entity-type :table) (t2/hydrate :fields :db)
(= entity-type :transform) (t2/hydrate :table-with-db-and-fields)
(= entity-type :dashboard) (-> (t2/hydrate :creator [:collection :is_personal] :moderation_reviews)
(->> (map collection.root/hydrate-root-collection))
(revisions/with-last-edit-info :dashboard))
(= entity-type :document) (-> (t2/hydrate :creator [:collection :is_personal])
(->> (map collection.root/hydrate-root-collection)))
(= entity-type :sandbox) (t2/hydrate [:table :db :fields]))
(mapv #(entity-value entity-type % usages))))
(let [model (entity-model entity-type)
fields (entity-select-fields entity-type)]
(->> (cond-> (t2/select (into [model] fields) :id [:in entity-ids])
(= entity-type :card) (-> (t2/hydrate :creator :dashboard [:collection :is_personal] :moderation_reviews)
(->> (map collection.root/hydrate-root-collection))
(revisions/with-last-edit-info :card))
(= entity-type :table) (t2/hydrate :fields :db)
(= entity-type :transform) (t2/hydrate :table-with-db-and-fields)
(= entity-type :dashboard) (-> (t2/hydrate :creator [:collection :is_personal] :moderation_reviews)
(->> (map collection.root/hydrate-root-collection))
(revisions/with-last-edit-info :dashboard))
(= entity-type :document) (-> (t2/hydrate :creator [:collection :is_personal])
(->> (map collection.root/hydrate-root-collection)))
(= entity-type :sandbox) (t2/hydrate [:table :db :fields]))
(mapv #(entity-value entity-type % usages)))))
nodes-by-type)))
(api.macros/defendpoint :get "/graph"
"This endpoint takes an :id and a supported entity :type, and returns a graph of all its upstream dependencies.
The graph is represented by a list of :nodes and a list of :edges. Each node has an :id, :type, :data (which
depends on the node type), and a map of :dependent_counts per entity type. Each edge is a :model/Dependency"
depends on the node type), and a map of :dependent_counts per entity type. Each edge is a :model/Dependency.
Optional :archived parameter controls whether entities in archived collections are included:
- false (default): Excludes entities in archived collections
- true: Includes entities in archived collections"
[_route-params
{:keys [id type]} :- [:map
[:id {:optional true} ms/PositiveInt]
[:type {:optional true} (ms/enum-decode-keyword (vec (keys entity-model)))]]]
{:keys [id type archived]} :- [:map
[:id {:optional true} ms/PositiveInt]
[:type {:optional true} (ms/enum-decode-keyword (vec (keys entity-model)))]
[:archived {:optional true} :boolean]]]
(api/read-check (entity-model type) id)
(let [starting-nodes [[type id]]
upstream-graph (readable-graph-dependencies)
;; cache the downstream graph specifically, because between calculating transitive children and calculating
;; edges, we'll call this multiple times on the same nodes.
downstream-graph (graph/cached-graph (readable-graph-dependents))
nodes (into (set starting-nodes)
(graph/transitive upstream-graph starting-nodes))
edges (graph/calc-edges-between downstream-graph nodes)]
{:nodes (expanded-nodes downstream-graph nodes)
:edges edges}))
(lib-be/with-metadata-provider-cache
(let [graph-opts {:include-archived-items (if archived :all :exclude)}
starting-nodes [[type id]]
upstream-graph (readable-graph-dependencies graph-opts)
;; cache the downstream graph specifically, because between calculating transitive children and calculating
;; edges, we'll call this multiple times on the same nodes.
downstream-graph (graph/cached-graph (readable-graph-dependents graph-opts))
nodes (into (set starting-nodes)
(graph/transitive upstream-graph starting-nodes))
edges (graph/calc-edges-between downstream-graph nodes)]
{:nodes (expanded-nodes downstream-graph nodes)
:edges edges})))
(def ^:private dependents-args
[:map
@@ -266,22 +369,29 @@
[:type (ms/enum-decode-keyword (vec (keys entity-model)))]
[:dependent_type (ms/enum-decode-keyword (vec (keys entity-model)))]
[:dependent_card_type {:optional true} (ms/enum-decode-keyword
[:question :model :metric])]])
[:question :model :metric])]
[:archived {:optional true} :boolean]])
(api.macros/defendpoint :get "/graph/dependents"
"This endpoint takes an :id, :type, :dependent_type, and an optional :dependent_card_type, and returns a list of
all that entity's dependents with :dependent_type. If the :dependent_type is :card, the dependents are further
filtered by :dependent_card_type."
filtered by :dependent_card_type.
Optional :archived parameter controls whether entities in archived collections are included:
- false (default): Excludes entities in archived collections
- true: Includes entities in archived collections"
[_route-params
{:keys [id type dependent_type dependent_card_type]} :- dependents-args]
{:keys [id type dependent_type dependent_card_type archived]} :- dependents-args]
(api/read-check (entity-model type) id)
(let [downstream-graph (graph/cached-graph (readable-graph-dependents))
nodes (-> (graph/children-of downstream-graph [[type id]])
(get [type id]))]
(->> (expanded-nodes downstream-graph nodes)
(filter #(and (= (:type %) dependent_type)
(or (not= dependent_type :card)
(= (-> % :data :type) dependent_card_type)))))))
(lib-be/with-metadata-provider-cache
(let [graph-opts {:include-archived-items (if archived :all :exclude)}
downstream-graph (graph/cached-graph (readable-graph-dependents graph-opts))
nodes (-> (graph/children-of downstream-graph [[type id]])
(get [type id]))]
(->> (expanded-nodes downstream-graph nodes)
(filter #(and (= (:type %) dependent_type)
(or (not= dependent_type :card)
(= (-> % :data :type) dependent_card_type))))))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/dependencies` routes."

View File

@@ -21,30 +21,55 @@
{:from_entity_type mi/transform-keyword
:to_entity_type mi/transform-keyword})
(defn- deps-children [src-type src-id dst-type dst-id key-seq]
;; Group all keys with the same type together, so we make O(types) indexed [[t2/select]] calls, not O(n).
(transduce (map (fn [[entity-type entity-keys]]
(let [deps (t2/select :model/Dependency
src-type entity-type
src-id [:in entity-keys])]
(u/group-by (juxt src-type src-id)
(juxt dst-type dst-id)
conj #{}
deps))))
merge {}
(u/group-by first second key-seq)))
(defn- deps-children
"Get dependency children with optional database-level filtering.
Returns a map from [src-type src-id] tuples to sets of [dst-type dst-id] tuples representing dependencies.
When `destination-filter-fn` is provided, it should be a function accepting two arguments
(entity-type-field, entity-id-field) and returning a HoneySQL WHERE clause for filtering destination entities."
([src-type src-id dst-type dst-id key-seq]
(deps-children src-type src-id dst-type dst-id key-seq nil))
([src-type src-id dst-type dst-id key-seq destination-filter-fn]
(let [base-filter (cond-> [:and]
destination-filter-fn (conj (destination-filter-fn dst-type dst-id)))]
(transduce (map (fn [[entity-type entity-keys]]
(let [full-filter (conj base-filter
[:= src-type (name entity-type)]
[:in src-id entity-keys])
deps (t2/select :model/Dependency {:where full-filter})]
(u/group-by (juxt src-type src-id)
(juxt dst-type dst-id)
conj #{}
deps))))
merge {}
(u/group-by first second key-seq)))))
(defn- key-dependents
"Get the dependent entity keys for the entity keys in `entity-keys`.
Entity keys are [entity-type, entity-id] pairs. See [[entity-type->model]]."
[key-seq]
(deps-children :to_entity_type :to_entity_id :from_entity_type :from_entity_id key-seq))
"Get the dependent entity keys for the entity keys in `key-seq`.
Entity keys are [entity-type entity-id] tuples. Returns a map from source entity keys
to sets of dependent (downstream) entity keys.
When `destination-filter-fn` is provided, it should be a function accepting two arguments
(entity-type-field, entity-id-field) and returning a HoneySQL WHERE clause for filtering."
([key-seq]
(key-dependents key-seq nil))
([key-seq destination-filter-fn]
(deps-children :to_entity_type :to_entity_id :from_entity_type :from_entity_id key-seq destination-filter-fn)))
(defn- key-dependencies
"Get the dependency entity keys for the entity keys in `entity-keys`, a seq of keys.
Entity keys are [entity-type, entity-id] pairs. See [[entity-type->model]]."
[key-seq]
(deps-children :from_entity_type :from_entity_id :to_entity_type :to_entity_id key-seq))
"Get the dependency entity keys for the entity keys in `key-seq`.
Entity keys are [entity-type entity-id] tuples. Returns a map from source entity keys
to sets of dependency (upstream) entity keys.
When `destination-filter-fn` is provided, it should be a function accepting two arguments
(entity-type-field, entity-id-field) and returning a HoneySQL WHERE clause for filtering."
([key-seq]
(key-dependencies key-seq nil))
([key-seq destination-filter-fn]
(deps-children :from_entity_type :from_entity_id :to_entity_type :to_entity_id key-seq destination-filter-fn)))
(p/deftype+ DependencyGraph [children-fn]
graph/Graph
@@ -61,27 +86,35 @@
[]
(->DependencyGraph key-dependencies))
(defn filtered-graph-dependencies
"Return a dependency graph for finding dependencies (upstream entities), filtered by `node-predicate`.
The predicate receives entity keys as `[entity-type entity-id]` and should return true for nodes to include."
[node-predicate]
(defn- filtered-graph
"Create a dependency graph with database-level filtering.
Arguments:
- `key-fn`: Either key-dependencies or key-dependents, determining graph direction
- `destination-filter-fn`: Function accepting (entity-type-field, entity-id-field)
and returning a HoneySQL WHERE clause"
[key-fn destination-filter-fn]
(->DependencyGraph
(fn [key-seq]
(into {}
(map (fn [[k v]]
[k (filterv node-predicate v)]))
(key-dependencies key-seq)))))
(key-fn key-seq destination-filter-fn))))
(defn filtered-graph-dependencies
"Create a permission-aware dependency graph for finding upstream dependencies.
Arguments:
- `destination-filter-fn`: Function accepting (entity-type-field, entity-id-field)
and returning a HoneySQL WHERE clause for filtering destination entities"
[destination-filter-fn]
(filtered-graph key-dependencies destination-filter-fn))
(defn filtered-graph-dependents
"Return a dependency graph for finding dependents (downstream entities), filtered by `node-predicate`.
The predicate receives entity keys as `[entity-type entity-id]` and should return true for nodes to include."
[node-predicate]
(->DependencyGraph
(fn [key-seq]
(into {}
(map (fn [[k v]]
[k (filterv node-predicate v)]))
(key-dependents key-seq)))))
"Create a permission-aware dependency graph for finding downstream dependents.
Arguments:
- `destination-filter-fn`: Function accepting (entity-type-field, entity-id-field)
and returning a HoneySQL WHERE clause for filtering destination entities"
[destination-filter-fn]
(filtered-graph key-dependents destination-filter-fn))
(defn transitive-dependents
"Given a map of updated entities `{entity-type [{:id 1, ...} ...]}`, return a map of its transitive dependents

View File

@@ -197,6 +197,8 @@
_query-params
{:keys [name document collection_id collection_position cards] :as body} :- DocumentUpdateOptions]
(let [existing-document (api/check-404 (get-document document-id))]
(when-not (contains? body :archived)
(api/check-not-archived existing-document))
(api/write-check existing-document)
(when (api/column-will-change? :collection_id existing-document body)
(m.document/validate-collection-move-permissions (:collection_id existing-document) collection_id))

View File

@@ -326,7 +326,7 @@
[:and
[:map
[:name :string]
[:type [:enum :number, :string, :date, :datetime, :time, :boolean, :null]]
[:type [:enum :number, :string, :date, :datetime, :time, :boolean, nil]]
[:description {:optional true} [:maybe :string]]
[:table-reference {:optional true} :string]]
[:map {:encode/ai-service-request #(set/rename-keys % {:table-reference :table_reference})}]])

View File

@@ -12,6 +12,7 @@
[metabase.lib.types.isa :as lib.types.isa]
[metabase.util :as u]
[metabase.util.humanization :as u.humanization]
[metabase.util.log :as log]
[metabase.warehouse-schema.models.field-values :as field-values]
[toucan2.core :as t2]))
@@ -27,16 +28,39 @@
{:order-by [[:id :desc]]})]
(= (:status review) "verified")))
(def ^:private max-glossary-items
"Maximal number of items from glossary to include in Metabot's context."
100)
(def ^:private glossary-order-column
"Column to order by when selecting glossary items for Metabot's context injection."
:updated_at)
(defn- glossary-for-context
[]
;; Rather small glossary sizes are anticipated. Additional count is bearable.
(let [glossary-size (t2/count :model/Glossary)]
(when (> glossary-size max-glossary-items)
;; If we are notified about the following warning we should reconsider current,
;; context injection, approach to glossary integration into Metabot.
(log/warnf "Glossary size is larger than limit for context injection (%d > %d)."
glossary-size max-glossary-items)))
(not-empty (t2/select-fn->fn :term :definition :model/Glossary
{:order-by [[glossary-order-column :desc]]
:limit max-glossary-items})))
(defn get-current-user
"Get information about the current user."
[]
(if-let [{:keys [id email first_name last_name]}
(or (some-> api/*current-user* deref)
(t2/select-one [:model/User :id :email :first_name :last_name] api/*current-user-id*))]
{:structured-output {:id id
:type :user
:name (str first_name " " last_name)
:email-address email}}
{:structured-output (merge {:id id
:type :user
:name (str first_name " " last_name)
:email-address email}
(when-some [glossary (glossary-for-context)]
{:glossary glossary}))}
{:output "current user not found"}))
(defn get-dashboard-details

View File

@@ -463,7 +463,8 @@
[:id :int]
[:type [:= :user]]
[:name :string]
[:email_address :string]]]]
[:email_address :string]
[:glossary [:maybe [:map-of :string :string]]]]]]
[:map [:output :string]]])
(mr/def ::get-dashboard-details-result
[:or
@@ -602,20 +603,17 @@
:with_metric_default_temporal_breakout :with-default-temporal-breakout?})}]])
(mr/def ::table-result
[:schema
{:registry {::table-result
[:map
[:id :int]
[:type [:enum :model :table]]
[:name :string]
[:display_name :string]
[:database_id :int]
[:database_schema {:optional true} [:maybe :string]] ; Schema name, if applicable
[:fields ::columns]
[:related_tables {:optional true} [:sequential [:ref ::table-result]]]
[:description {:optional true} [:maybe :string]]
[:metrics {:optional true} [:sequential ::basic-metric]]]}}
::table-result])
[:map
[:id :int]
[:type [:enum :model :table]]
[:name :string]
[:display_name :string]
[:database_id :int]
[:database_schema {:optional true} [:maybe :string]] ; Schema name, if applicable
[:fields ::columns]
[:related_tables {:optional true} [:sequential [:ref ::table-result]]]
[:description {:optional true} [:maybe :string]]
[:metrics {:optional true} [:sequential ::basic-metric]]])
(mr/def ::get-table-details-result
[:or

View File

@@ -46,7 +46,3 @@
(defenterprise decode-airgap-token
"Decodes the airgap token and returns the decoded token."
:feature :none [token] (decode-token token))
(defenterprise token-valid-now?
"Check that the decoded token is still valid and returns the decoded token."
:feature :none [token-status] (valid-now? token-status))

View File

@@ -55,9 +55,9 @@
{:terms_of_service true})))))
(testing "succeeds"
(let [{store-api-proxy :proxy store-api-calls :calls} (semantic.tu/spy (constantly nil))
{clear-token-cache-proxy :proxy clear-token-cache-calls :calls} (semantic.tu/spy premium-features/clear-cache)]
(with-redefs [hm.client/call store-api-proxy
premium-features/clear-cache clear-token-cache-proxy]
{clear-token-cache-proxy :proxy clear-token-cache-calls :calls} (semantic.tu/spy premium-features/clear-cache!)]
(with-redefs [hm.client/call store-api-proxy
premium-features/clear-cache! clear-token-cache-proxy]
(is (=? {}
(mt/user-http-request user :post 200 "ee/cloud-add-ons/metabase-ai"
{:terms_of_service true})))

View File

@@ -316,6 +316,57 @@
:type "card"}]
response))))))))
(deftest graph-archived-card-test
(testing "GET /api/ee/dependencies/graph with archived parameter"
(mt/with-premium-features #{:dependencies}
(mt/with-model-cleanup [:model/Card :model/Dependency]
(mt/with-temp [:model/User user {:email "test@test.com"}]
(let [base-card (card/create-card! (basic-card "Archived Base Card") user)
dependent-card (card/create-card! (wrap-card base-card) user)]
(card/update-card! {:card-before-update base-card
:card-updates {:archived true}})
(testing "archived=false (default) excludes archived card from dependencies"
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph"
:id (:id dependent-card)
:type "card")
node-ids (set (map :id (:nodes response)))]
(is (contains? node-ids (:id dependent-card)))
(is (not (contains? node-ids (:id base-card))))))
(testing "archived=true includes archived card in dependencies"
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph"
:id (:id dependent-card)
:type "card"
:archived true)
node-ids (set (map :id (:nodes response)))]
(is (contains? node-ids (:id dependent-card)))
(is (contains? node-ids (:id base-card)))
(is (contains? node-ids (mt/id :orders)))))))))))
(deftest dependents-archived-card-test
(testing "GET /api/ee/dependencies/graph/dependents with archived parameter"
(mt/with-premium-features #{:dependencies}
(mt/with-model-cleanup [:model/Card :model/Dependency]
(mt/with-temp [:model/User user {:email "test@test.com"}]
(let [base-card (card/create-card! (basic-card "Base Card") user)
dependent-card (card/create-card! (wrap-card base-card) user)]
(card/update-card! {:card-before-update dependent-card
:card-updates {:archived true}})
(testing "archived=false (default) excludes archived dependent"
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph/dependents"
:id (:id base-card)
:type "card"
:dependent_type "card")]
(is (empty? response))))
(testing "archived=true includes archived dependent"
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph/dependents"
:id (:id base-card)
:type "card"
:dependent_type "card"
:dependent_card_type "question"
:archived true)
dependent-ids (set (map :id response))]
(is (contains? dependent-ids (:id dependent-card)))))))))))
(deftest check-card-permissions-test
(testing "POST /api/ee/dependencies/check_card requires read permissions on the input card"
(mt/with-premium-features #{:dependencies}

View File

@@ -3,6 +3,7 @@
[clojure.set :as set]
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.dependencies.models.dependency :as deps.graph]
[metabase.graph.core :as graph]
[metabase.lib.core :as lib]
[metabase.queries.models.card :as card]
[metabase.test :as mt]
@@ -223,3 +224,71 @@
(let [card (card/create-card! (basic-orders) user)]
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Card :id (:id card))))))))))
(deftest filtered-graph-dependencies-test
(testing "filtered-graph-dependencies respects filter clause"
(mt/with-temp [:model/User user {:email "me@wherever.com"}]
(mt/with-premium-features #{:dependencies}
(mt/with-model-cleanup [:model/Card :model/Dependency]
(let [{id1 :id :as card1} (card/create-card! (basic-orders) user)
{id2 :id :as card2} (card/create-card! (wrap-card card1) user)
{id3 :id :as _card3} (card/create-card! (wrap-card card2) user)]
(testing "without filter, returns all dependencies"
(let [graph (deps.graph/graph-dependencies)
deps (graph/transitive graph [[:card id3]])]
(is (= #{[:card id2] [:card id1] [:table (mt/id :orders)]}
(set deps)))))
(testing "with filter excluding card1, omits card1 and its dependencies"
(let [filter-fn (fn [entity-type-field entity-id-field]
[:and
[:= entity-type-field "card"]
[:in entity-id-field [id2 id3]]])
graph (deps.graph/filtered-graph-dependencies filter-fn)
deps (graph/transitive graph [[:card id3]])]
(is (= #{[:card id2]}
(set deps))
"Should only include card2, not card1 or table")))
(testing "with filter excluding card2, breaks the chain"
(let [filter-fn (fn [entity-type-field entity-id-field]
[:and
[:= entity-type-field "card"]
[:in entity-id-field [id1 id3]]])
graph (deps.graph/filtered-graph-dependencies filter-fn)
deps (graph/transitive graph [[:card id3]])]
(is (= #{}
(set deps))
"Should be empty, chain is broken at card2")))))))))
(deftest filtered-graph-dependents-test
(testing "filtered-graph-dependents respects filter clause"
(mt/with-temp [:model/User user {:email "me@wherever.com"}]
(mt/with-premium-features #{:dependencies}
(mt/with-model-cleanup [:model/Card :model/Dependency]
(let [{id1 :id :as card1} (card/create-card! (basic-orders) user)
{id2 :id :as card2} (card/create-card! (wrap-card card1) user)
{id3 :id :as _card3} (card/create-card! (wrap-card card2) user)]
(testing "without filter, returns all dependents"
(let [graph (deps.graph/graph-dependents)
deps (graph/transitive graph [[:card id1]])]
(is (= #{[:card id2] [:card id3]}
(set deps)))))
(testing "with filter excluding card3, omits card3"
(let [filter-fn (fn [entity-type-field entity-id-field]
[:and
[:= entity-type-field "card"]
[:in entity-id-field [id1 id2]]])
graph (deps.graph/filtered-graph-dependents filter-fn)
deps (graph/transitive graph [[:card id1]])]
(is (= #{[:card id2]}
(set deps))
"Should only include card2, not card3")))
(testing "with filter excluding card2, breaks the chain"
(let [filter-fn (fn [entity-type-field entity-id-field]
[:and
[:= entity-type-field "card"]
[:in entity-id-field [id1 id3]]])
graph (deps.graph/filtered-graph-dependents filter-fn)
deps (graph/transitive graph [[:card id1]])]
(is (= #{}
(set deps))
"Should be empty, chain is broken at card2")))))))))

View File

@@ -83,6 +83,17 @@
(mt/user-http-request :rasta :put 403 (str "ee/document/" document-id)
{:name "Meow"}))))
(deftest put-document-archived-test
(testing "PUT /api/ee/document/:id - cannot update archived document"
(mt/with-temp [:model/Document {doc-id :id} {:name "Test Document"
:document (documents.test-util/text->prose-mirror-ast "Initial")
:archived true}]
(testing "editing archived document returns 404"
(is (= "The object has been archived."
(:message (mt/user-http-request :crowberto
:put 404 (format "ee/document/%s" doc-id)
{:name "Updated Name"}))))))))
(deftest post-document-with-no-perms-test
(mt/with-temp [:model/Collection {coll-id :id} {}]
(mt/with-non-admin-groups-no-collection-perms coll-id
@@ -2318,7 +2329,7 @@
{:name "Doc with bad reference"
:collection_id remote-synced-id
:document (prose-mirror-with-smartlink "Link to card" card-id)})]
(is (= "Model has non-remote-synced dependencies" (:message response))
(is (= "Uses content that is not remote synced." (:message response))
"Should report non-remote-synced dependencies"))))))))
(deftest create-document-in-remote-synced-with-remote-synced-smartlink-test
@@ -2394,7 +2405,7 @@
(let [response (mt/user-http-request :crowberto
:put 400 (format "ee/document/%s" doc-id)
{:document (prose-mirror-with-smartlink "Bad link" card-id)})]
(is (= "Model has non-remote-synced dependencies" (:message response)))))))))
(is (= "Uses content that is not remote synced." (:message response)))))))))
(deftest update-document-in-remote-synced-with-remote-synced-smartlink-test
(testing "PUT /api/ee/document/:id - updating document in remote-synced collection to add remote-synced reference"
@@ -2499,7 +2510,7 @@
(let [response (mt/user-http-request :crowberto
:put 400 (format "ee/document/%s" doc-id)
{:collection_id remote-synced-id})]
(is (= "Model has non-remote-synced dependencies" (:message response)))
(is (= "Uses content that is not remote synced." (:message response)))
;; Verify document was NOT moved
(is (= regular-id (:collection_id (t2/select-one :model/Document :id doc-id)))
@@ -2529,7 +2540,7 @@
:put 400 (format "ee/document/%s" doc-id)
{:document (prose-mirror-with-smartlink "Bad" regular-card-id)
:collection_id remote-synced-id})]
(is (= "Model has non-remote-synced dependencies" (:message response)))))
(is (= "Uses content that is not remote synced." (:message response)))))
(testing "Does not throw when moving to a regular collection"
(mt/user-http-request :crowberto

View File

@@ -6,10 +6,15 @@
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
[metabase-enterprise.metabot-v3.suggested-prompts :as metabot-v3.suggested-prompts]
[metabase-enterprise.metabot-v3.util :as metabot-v3.util]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.test :as mt]
[metabase.util.json :as json]
[metabase.util.malli :as mu])
[metabase.util.malli :as mu]
[toucan2.core :as t2])
(:import
(java.io ByteArrayInputStream ByteArrayOutputStream)
(metabase.server.streaming_response StreamingResponse)))
@@ -59,3 +64,34 @@
(is (=? [{:_type :TEXT :content "a1a2a3"}
{:_type :FINISH_MESSAGE :finish_reason "stop"}]
(metabot-v3.util/aisdk->messages "assistant" (str/split-lines body))))))))))
(deftest example-generation-payload-unknown-field-types-test
(let [mp (mt/metadata-provider)]
(mt/with-temp [:model/Card c {:type :metric
:dataset_query (-> (lib/query mp (lib.metadata/table mp (mt/id :orders)))
(lib/aggregate
(lib/count (lib.metadata/field mp (mt/id :orders :total)))))
;; Set large view count to ensure metric is picked in `generate-sample-prompts`.
:view_count 1000}]
;; Add column with type/* to metric result_metadata
(t2/update! :model/Card :id (:id c)
{:result_metadata (conj (:result_metadata (t2/select-one :model/Card :id (:id c)))
{:database_type "point"
:base_type :type/*
:effective_type :type/*
:name "dummy"})})
(testing "No exception is thrown when type/* column is present it result_metadata"
(is (= :not-thrown
(try
;; Override calls to ai service as we are interested in no exception being thrown
;; prior to those calls.
(with-redefs [metabot-v3.client/post! (constantly {:status 200
:body {:table_questions []
:metric_questions []}})]
(let [metabot-eid (get-in metabot-v3.config/metabot-config [metabot-v3.config/internal-metabot-id
:entity-id])
metabot-id (t2/select-one-fn :id :model/Metabot :entity_id metabot-eid)]
(metabot-v3.suggested-prompts/generate-sample-prompts metabot-id)
:not-thrown))
(catch Exception e
e))))))))

View File

@@ -493,17 +493,19 @@
(deftest ^:parallel get-current-user-test
(mt/with-premium-features #{:metabot-v3}
(let [conversation-id (str (random-uuid))
ai-token (ai-session-token)
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-current-user"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:conversation_id conversation-id})]
(is (=? {:structured_output {:id (mt/user->id :rasta)
:type "user"
:name "Rasta Toucan"
:email_address "rasta@metabase.com"}
:conversation_id conversation-id}
response)))))
(mt/with-temp [:model/Glossary _ {:term "asdf" :definition "Sales Team Performance"}]
(let [conversation-id (str (random-uuid))
ai-token (ai-session-token)
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-current-user"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:conversation_id conversation-id})]
(is (=? {:structured_output {:id (mt/user->id :rasta)
:type "user"
:name "Rasta Toucan"
:email_address "rasta@metabase.com"
:glossary {(keyword "asdf") "Sales Team Performance"}}
:conversation_id conversation-id}
response))))))
(deftest get-dashboard-details-test
(mt/with-premium-features #{:metabot-v3}
@@ -1225,8 +1227,9 @@
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
{:conversation_id conversation-id})
(update :structured_output (fn [output]
(filter #(#{(:id t1) (:id t2) (:id t3)} (:id %))
output)))))))))))
(->> output
(filter #(#{(:id t1) (:id t2) (:id t3)} (:id %)))
(sort-by :id))))))))))))
(deftest get-transform-test
(mt/with-premium-features #{:metabot-v3 :transforms}

View File

@@ -51,8 +51,8 @@
(let [response (mt/user-http-request :crowberto :put 400 (str "dashboard/" dash-id)
{:collection_id target-id})]
;; Verify error response contains dependency information
(is (str/includes? (:message response) "non-remote-synced dependencies")
"Error message should mention non-remote-synced dependencies"))
(is (str/includes? (:message response) "content that is not remote synced")
"Error message should mention content that is not remote synced"))
;; Verify the transaction was rolled back - dashboard should not be moved
(let [unchanged-dash (t2/select-one :model/Dashboard :id dash-id)]
@@ -150,8 +150,8 @@
(let [response (mt/user-http-request :crowberto :post 400 (format "dashboard/%d/copy" dash-id)
{:collection_id target-id})]
;; Verify error response contains dependency information
(is (str/includes? (:message response) "non-remote-synced dependencies")
"Error message should mention non-remote-synced dependencies"))
(is (str/includes? (:message response) "content that is not remote synced")
"Error message should mention content that is not remote synced"))
;; Verify no dashboard was created
(is (= 1 (t2/count :model/Dashboard :name "Dashboard to Copy"))

View File

@@ -196,6 +196,19 @@ describe("EditableDashboard", () => {
expect(dataPickerDataCallUrl).toContain("models=dataset");
expect(dataPickerDataCallUrl).not.toContain("models=table");
});
it("should show 'Add a chart' button on empty dashboards", async () => {
await setup({ dashcards: [] });
expect(screen.getByText("This dashboard is empty")).toBeInTheDocument();
expect(screen.getByText("Add a chart")).toBeInTheDocument();
});
it("should allow editing the dashboard title", async () => {
await setup();
expect(screen.getByTestId("dashboard-name-heading")).toBeEnabled();
});
});
function setupSimpleDataPickerEndpoints() {

View File

@@ -131,4 +131,17 @@ describe("InteractiveDashboard", () => {
dashboardHeader.queryByTestId("dashboard-header-row-button"),
).not.toBeInTheDocument();
});
it("should not show 'Add a chart' button on empty dashboards", async () => {
await setup({ dashcards: [] });
expect(screen.getByText("This dashboard is empty")).toBeInTheDocument();
expect(screen.queryByText("Add a chart")).not.toBeInTheDocument();
});
it("should not allow editing the dashboard title", async () => {
await setup();
expect(screen.getByTestId("dashboard-name-heading")).toBeDisabled();
});
});

View File

@@ -342,7 +342,6 @@ export const ExperimentalLayout = {
<Paper>
<SdkDashboard.ExportAsPdfButton />
<SdkDashboard.InfoButton />
<SdkDashboard.NightModeButton />
<SdkDashboard.RefreshPeriod />
</Paper>
</Popover.Dropdown>

View File

@@ -375,7 +375,6 @@ export const SdkDashboard = withPublicComponentWrapper(
| "FullscreenButton"
| "ExportAsPdfButton"
| "InfoButton"
| "NightModeButton"
| "RefreshPeriod"
>;
@@ -387,7 +386,6 @@ SdkDashboard.ParametersList = Dashboard.ParametersList;
SdkDashboard.FullscreenButton = Dashboard.FullscreenButton;
SdkDashboard.ExportAsPdfButton = Dashboard.ExportAsPdfButton;
SdkDashboard.InfoButton = Dashboard.InfoButton;
SdkDashboard.NightModeButton = Dashboard.NightModeButton;
SdkDashboard.RefreshPeriod = Dashboard.RefreshPeriod;
type DashboardQueryBuilderProps = {

View File

@@ -100,4 +100,17 @@ describe("StaticDashboard", () => {
dashboardHeader.queryByTestId("dashboard-header-row-button"),
).not.toBeInTheDocument();
});
it("should not show 'Add a chart' button on empty dashboards", async () => {
await setup({ dashcards: [] });
expect(screen.getByText("This dashboard is empty")).toBeInTheDocument();
expect(screen.queryByText("Add a chart")).not.toBeInTheDocument();
});
it("should not allow editing the dashboard title", async () => {
await setup();
expect(screen.getByTestId("dashboard-name-heading")).toBeDisabled();
});
});

View File

@@ -23,6 +23,7 @@ import { setupSdkState } from "embedding-sdk-bundle/test/server-mocks/sdk-init";
import type { MetabaseProviderProps } from "embedding-sdk-bundle/types/metabase-provider";
import { useLocale } from "metabase/common/hooks/use-locale";
import { Box } from "metabase/ui";
import type { DashboardCard } from "metabase-types/api";
import {
createMockCard,
createMockCardQueryMetadata,
@@ -102,7 +103,11 @@ export const textDashcard2 = createMockTextDashboardCard({
dashboard_tab_id: dashboardTabs[1].id,
});
export const dashcards = [tableDashcard, textDashcard, textDashcard2];
export const DEFAULT_DASHCARDS: DashboardCard[] = [
tableDashcard,
textDashcard,
textDashcard2,
];
export interface SetupSdkDashboardOptions {
props?: Partial<SdkDashboardProps>;
@@ -111,6 +116,7 @@ export interface SetupSdkDashboardOptions {
component: React.ComponentType<SdkDashboardProps>;
dashboardName?: string;
dataPickerProps?: EditableDashboardProps["dataPickerProps"];
dashcards?: DashboardCard[];
}
jest.mock("metabase/common/hooks/use-locale", () => ({
@@ -124,6 +130,7 @@ export const setupSdkDashboard = async ({
component: Component,
dashboardName = "Dashboard",
dataPickerProps,
dashcards = DEFAULT_DASHCARDS,
}: SetupSdkDashboardOptions) => {
const useLocaleMock = useLocale as jest.Mock;
useLocaleMock.mockReturnValue({ isLocaleLoading });
@@ -212,7 +219,14 @@ export const setupSdkDashboard = async ({
);
if (!isLocaleLoading) {
expect(await screen.findByTestId("dashboard-grid")).toBeInTheDocument();
if (dashcards.length === 0) {
// For empty dashboards, wait for the empty state instead of the grid
expect(
await screen.findByTestId("dashboard-empty-state"),
).toBeInTheDocument();
} else {
expect(await screen.findByTestId("dashboard-grid")).toBeInTheDocument();
}
}
return {

View File

@@ -138,7 +138,8 @@ export const DocumentPage = ({
const hasComments =
!!commentsData?.comments && commentsData.comments.length > 0;
const canWrite = isNewDocument || documentData?.can_write;
const canWrite =
!documentData?.archived && (isNewDocument || documentData?.can_write);
useEffect(() => {
if (error) {
@@ -198,7 +199,10 @@ export const DocumentPage = ({
const hasUnsavedChanges = useCallback(() => {
const currentTitle = documentTitle.trim();
const originalTitle = documentData?.name || "";
const titleChanged = currentTitle !== originalTitle;
// We call .trim() on documentTitle to ensure that no one can push the save button
// with a document name that is all whitespace, the API will reject it. However,
// when comparing saved with current titles, we need to use unmofidied values
const titleChanged = documentTitle !== originalTitle;
// Check if there are any draft cards
const hasDraftCards = Object.keys(draftCards).length > 0;
@@ -455,7 +459,6 @@ export const DocumentPage = ({
<CollectionPickerModal
title={t`Where should we save this document?`}
onClose={() => setCollectionPickerMode(null)}
value={{ id: "root", model: "collection" }}
options={{
showPersonalCollections: true,
showRootCollection: true,

View File

@@ -0,0 +1,66 @@
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import {
setupBookmarksEndpoints,
setupCommentEndpoints,
setupDocumentEndpoints,
} from "__support__/server-mocks";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import { createMockDocument } from "metabase-types/api/mocks";
import { DocumentPage } from "./DocumentPage";
const setup = () => {
setupBookmarksEndpoints([]);
setupDocumentEndpoints(
createMockDocument({
name: "Ends with whitepsace ",
id: 1,
can_write: true,
}),
);
setupCommentEndpoints([], { target_type: "document", target_id: 1 });
renderWithProviders(
<>
<Route path="/document/:entityId" component={DocumentPage}></Route>
</>,
{
withRouter: true,
initialRoute: "/document/1",
},
);
};
const getDocumentTitle = async () =>
await screen.findByRole("textbox", { name: /document title/i });
describe("Document Page", () => {
it("should show a save button when title changes", async () => {
setup();
await waitFor(async () =>
expect(await getDocumentTitle()).toHaveValue("Ends with whitepsace "),
);
await userEvent.clear(await getDocumentTitle());
await userEvent.type(await getDocumentTitle(), "New Title");
expect(
await screen.findByRole("button", { name: "Save" }),
).toBeInTheDocument();
// Change the title back to the old value, the save button should disappear.
// I don't love how this test works, but asserting that something doesn't exist
// is a very easy way to get false positives in your test. Asserting that something
// doesn't exist *anymore* should be more robust
await userEvent.clear(await getDocumentTitle());
await userEvent.type(await getDocumentTitle(), "Ends with whitepsace ");
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Save" }),
).not.toBeInTheDocument(),
);
});
});

View File

@@ -4,12 +4,15 @@ import { t } from "ttag";
import { ColorPillPicker } from "metabase/common/components/ColorPicker";
import { useSetting } from "metabase/common/hooks";
import type { MetabaseColors } from "metabase/embedding-sdk/theme";
import { colors, staticVizOverrides } from "metabase/lib/colors";
import { originalColors, staticVizOverrides } from "metabase/lib/colors";
import { ActionIcon, Group, Icon, Stack, Text, Tooltip } from "metabase/ui";
import { getConfigurableThemeColors } from "../utils/theme-colors";
const defaultMetabaseColorsWithoutAlpha = { ...colors, ...staticVizOverrides };
const defaultMetabaseColorsWithoutAlpha = {
...originalColors,
...staticVizOverrides,
};
interface ColorCustomizationSectionProps {
theme?: { colors?: Partial<MetabaseColors> };

View File

@@ -97,7 +97,7 @@ export function MetabotAdminPage() {
<MetabotCollectionConfigurationPane metabot={metabot} />
)}
<MetabotPromptSuggestionPane metabotId={metabotId} />
<MetabotPromptSuggestionPane metabot={metabot} />
</SettingsSection>
</ErrorBoundary>
</AdminSettingsLayout>

View File

@@ -26,15 +26,15 @@ import {
} from "metabase-enterprise/api";
import { FIXED_METABOT_IDS } from "metabase-enterprise/metabot/constants";
import * as Urls from "metabase-enterprise/urls";
import type { MetabotId, SuggestedMetabotPrompt } from "metabase-types/api";
import type { MetabotInfo, SuggestedMetabotPrompt } from "metabase-types/api";
export const PAGE_SIZE = 10;
export const MetabotPromptSuggestionPane = ({
metabotId,
metabot,
pageSize = PAGE_SIZE,
}: {
metabotId: MetabotId;
metabot: Pick<MetabotInfo, "id" | "collection_id">;
pageSize?: number;
}) => {
const [sendToast] = useToast();
@@ -43,7 +43,7 @@ export const MetabotPromptSuggestionPane = ({
const offset = page * pageSize;
const { data, isLoading, error } = useGetSuggestedMetabotPromptsQuery({
metabot_id: metabotId,
metabot_id: metabot.id,
limit: pageSize,
offset,
});
@@ -53,7 +53,7 @@ export const MetabotPromptSuggestionPane = ({
const handleDeletePrompt = async (promptId: SuggestedMetabotPrompt["id"]) => {
const { error } = await deletePrompt({
metabot_id: metabotId,
metabot_id: metabot.id,
prompt_id: promptId,
});
@@ -73,7 +73,7 @@ export const MetabotPromptSuggestionPane = ({
};
const handleRegeneratePrompts = async () => {
const { error } = await regeneratePrompts(metabotId);
const { error } = await regeneratePrompts(metabot.id);
if (error) {
sendToast({
message: t`Error regenerate prompts`,
@@ -101,7 +101,11 @@ export const MetabotPromptSuggestionPane = ({
<SettingHeader
id="prompt-suggestions"
title={t`Prompt suggestions`}
description={t`When users open a new Metabot chat, we’ll randomly show them a few suggested prompts based on the models and metrics in the collection you chose.`}
description={
metabot.collection_id
? t`When users open a new Metabot chat, we’ll show them a few suggested prompts based on popular models and metrics in the collection you chose.`
: t`When users open a new Metabot chat, we’ll show them a few suggested prompts based on popular models and metrics in your instance.`
}
/>
<Flex gap="md" align="center">
<Button
@@ -140,7 +144,7 @@ export const MetabotPromptSuggestionPane = ({
key={row.id}
row={row as SuggestedMetabotPrompt}
onDelete={() => handleDeletePrompt(row.id)}
metabotId={metabotId}
metabotId={metabot.id}
/>
)
}

View File

@@ -59,7 +59,10 @@ const setup = async (opts?: SetupOpts) => {
: paginationContext;
const TestComponent = () => (
<MetabotPromptSuggestionPane metabotId={metabotId} pageSize={pageSize} />
<MetabotPromptSuggestionPane
metabot={{ id: metabotId, collection_id: null }}
pageSize={pageSize}
/>
);
renderWithProviders(<Route path="/" component={TestComponent} />, {

View File

@@ -884,7 +884,6 @@ export const CardEmbedComponent = memo(
onSave={(result) => {
updateAttributes({
id: result.card_id,
name: null,
});
setIsModifyModalOpen(false);
}}
@@ -897,7 +896,6 @@ export const CardEmbedComponent = memo(
onSave={(result) => {
updateAttributes({
id: result.card_id,
name: null,
});
setIsModifyModalOpen(false);
}}

View File

@@ -22,7 +22,7 @@ interface ModifyQuestionModalProps {
card: Card;
isOpen: boolean;
onClose: () => void;
onSave: (result: { card_id: number; name: string }) => void;
onSave: (result: { card_id: number }) => void;
}
export const ModifyQuestionModal = ({
@@ -110,7 +110,7 @@ export const ModifyQuestionModal = ({
}),
);
onSave({ card_id: newCardId, name: card.name });
onSave({ card_id: newCardId });
onClose();
} catch (error) {
console.error("Failed to save modified question:", error);

View File

@@ -33,7 +33,7 @@ interface NativeQueryModalProps {
card: Card;
isOpen: boolean;
onClose: () => void;
onSave: (result: { card_id: number; name: string }) => void;
onSave: (result: { card_id: number }) => void;
initialDataset?: Dataset;
}
@@ -231,7 +231,7 @@ export const NativeQueryModal = ({
}),
);
onSave({ card_id: newCardId, name: card.name });
onSave({ card_id: newCardId });
onClose();
}, [modifiedQuestion, card, dispatch, onSave, onClose]);

View File

@@ -134,7 +134,7 @@ export const findNodeParentAndPos = (
}
});
if (parentPos && parent) {
if (parentPos !== null && parent !== null) {
return { parentPos, parent };
}

View File

@@ -1,3 +1,4 @@
import Color from "color";
import { memo, useCallback, useMemo } from "react";
import { t } from "ttag";
import _ from "underscore";
@@ -113,7 +114,7 @@ const BrandColorRow = memo(function BrandColorRow({
<TableBodyCell>
<ColorPicker
value={color ?? originalColor}
placeholder={originalColor}
placeholder={Color(originalColor).hex()}
onChange={handleChange}
/>
</TableBodyCell>

View File

@@ -27,7 +27,7 @@ describe("ColorSettings", () => {
/>,
);
const input = screen.getByPlaceholderText(color("summarize"));
const input = screen.getByPlaceholderText(Color(color("summarize")).hex());
await userEvent.clear(input);
await userEvent.type(input, color("error"));

View File

@@ -3,6 +3,7 @@ import { useDebouncedCallback } from "@mantine/hooks";
import { SetByEnvVar } from "metabase/admin/settings/components/widgets/AdminSettingInput";
import { useAdminSetting } from "metabase/api/utils";
import { originalColors } from "metabase/lib/colors/palette";
import { useMantineTheme } from "metabase/ui";
import type { ColorSettings as ColorSettingsType } from "metabase-types/api";
import { ColorSettings } from "../ColorSettings";
@@ -13,12 +14,14 @@ export const ColorSettingsWidget = () => {
updateSetting,
settingDetails,
} = useAdminSetting("application-colors");
const theme = useMantineTheme();
const handleChange = (newValue: ColorSettingsType) => {
updateSetting({
const handleChange = async (newValue: ColorSettingsType) => {
await updateSetting({
key: "application-colors",
value: newValue,
});
theme.other?.updateColorSettings?.(newValue);
};
const onChangeDebounced = useDebouncedCallback(handleChange, 400);

View File

@@ -131,8 +131,17 @@ export const isPK = (field) => field && isTypePK(field.semantic_type);
export const isEntityName = (field) =>
field && isa(field.semantic_type, TYPE.Name);
export const isTitle = (field) => field && isa(field.semantic_type, TYPE.Title);
export const isProduct = (field) =>
field && isa(field.semantic_type, TYPE.Product);
export const isSource = (field) =>
field && isa(field.semantic_type, TYPE.Source);
export const isAddress = (field) =>
field && isa(field.semantic_type, TYPE.Address);
export const isScore = (field) => field && isa(field.semantic_type, TYPE.Score);
export const isQuantity = (field) =>
field && isa(field.semantic_type, TYPE.Quantity);
export const isCategory = (field) =>
field && isa(field.semantic_type, TYPE.Category);
export const isAny = (col) => true;
@@ -165,6 +174,7 @@ export const isNumber = (field) =>
(field.semantic_type == null ||
isa(field.semantic_type, TYPE.Number) ||
isa(field.semantic_type, TYPE.Category));
export const isFloat = (field) => field && isa(field.semantic_type, TYPE.Float);
export const isTime = (field) => {
if (!field) {

View File

@@ -1,4 +1,5 @@
import type { EmbeddingParameters } from "metabase/public/lib/types";
import type { IconName } from "metabase/ui";
import type { PieRow } from "metabase/visualizations/echarts/pie/model/types";
import type { Collection, CollectionId, LastEditInfo } from "./collection";
@@ -330,8 +331,11 @@ export type VisualizationSettings = {
"sankey.label_value_formatting"?: "auto" | "full" | "compact";
// List view settings
"list.columns"?: ListViewColumns;
"list.entity_icon"?: string;
"list.columns"?: ListViewColumns; // set of columns selected for custom list view
"list.entity_icon_enabled"?: boolean; // display/hide first list item column rendering image/icon
"list.use_image_column"?: boolean; // render image from image/avatar url column instead of icon
"list.entity_icon"?: IconName | null;
"list.entity_icon_color"?: string;
[key: string]: any;
} & EmbedVisualizationSettings;

View File

@@ -1,3 +1,8 @@
// IMPORTANT: The field selections in the *DependencyNodeData types below
// MUST be kept in sync with the backend field selections in:
// enterprise/backend/src/metabase_enterprise/dependencies/api.clj
// (See entity-select-fields map)
import type { Card, CardType } from "./card";
import type { Dashboard } from "./dashboard";
import type { Document } from "./document";

View File

@@ -188,7 +188,7 @@ const getCollectionPermission = (
permissions: CollectionPermissions,
groupId: number,
collectionId: CollectionId,
) => getIn(permissions, [groupId, collectionId]);
) => getIn(permissions, [groupId, collectionId]) || "none";
const getNamespace = (_state: State, props: CollectionIdProps) =>
props.namespace;

View File

@@ -1,41 +0,0 @@
import { t } from "ttag";
import { ActionIcon, type ActionIconProps, Icon, Tooltip } from "metabase/ui";
import { useColorScheme } from "metabase/ui/components/theme/ColorSchemeProvider";
import type { ResolvedColorScheme } from "metabase/ui/components/theme/ColorSchemeProvider/ColorSchemeProvider";
const iconMap: Record<ResolvedColorScheme, "sun" | "moon"> = {
light: "sun",
dark: "moon",
};
const getTooltipText = (
scheme: ResolvedColorScheme,
systemScheme: ResolvedColorScheme,
): string => {
const baseText =
scheme === "dark" ? t`Switch to light mode` : t`Switch to dark mode`;
return systemScheme !== scheme
? `${baseText} ${t`(system preference)`}`
: baseText;
};
export function ColorSchemeToggle(props: ActionIconProps & { id?: string }) {
const { resolvedColorScheme, systemColorScheme, toggleColorScheme } =
useColorScheme();
const tooltipText = getTooltipText(resolvedColorScheme, systemColorScheme);
return (
<Tooltip label={tooltipText}>
<ActionIcon
onClick={toggleColorScheme}
aria-label={tooltipText}
{...props}
>
<Icon name={iconMap[resolvedColorScheme]} />
</ActionIcon>
</Tooltip>
);
}

View File

@@ -1,2 +1 @@
export * from "./ColorSchemeSelect";
export * from "./ColorSchemeToggle";

View File

@@ -29,7 +29,7 @@ export interface CollectionPickerModalProps {
onChange: (item: CollectionPickerValueItem) => void;
onClose: () => void;
options?: CollectionPickerOptions;
value: Pick<CollectionPickerValueItem, "id" | "model" | "collection_id">;
value?: Pick<CollectionPickerValueItem, "id" | "model" | "collection_id">;
shouldDisableItem?: (item: CollectionPickerItem) => boolean;
searchResultFilter?: (searchResults: SearchResult[]) => SearchResult[];
recentFilter?: (recentItems: RecentItem[]) => RecentItem[];
@@ -38,7 +38,7 @@ export interface CollectionPickerModalProps {
}
const baseCanSelectItem = (
item: Pick<CollectionPickerItem, "can_write" | "model"> | null,
item: Pick<CollectionPickerItem, "can_write" | "model"> | null | undefined,
): item is CollectionPickerValueItem => {
return (
!!item &&
@@ -62,17 +62,22 @@ export const CollectionPickerModal = ({
shouldDisableItem,
searchResultFilter,
recentFilter,
models = ["collection"],
models: modelsProp,
canSelectItem: _canSelectItem,
}: CollectionPickerModalProps) => {
options = { ...defaultOptions, ...options };
const models = modelsProp || ["collection"];
const [selectedItem, setSelectedItem] = useState<CollectionPickerItem | null>(
null,
);
const canSelectItem = useCallback(
(
item: Pick<CollectionPickerItem, "id" | "can_write" | "model"> | null,
item:
| Pick<CollectionPickerItem, "id" | "can_write" | "model">
| null
| undefined,
): item is CollectionPickerValueItem => {
return baseCanSelectItem(item) && (_canSelectItem?.(item) ?? true);
},
@@ -166,8 +171,8 @@ export const CollectionPickerModal = ({
displayName: models.some((model) => model !== "collection")
? t`Browse`
: t`Collections`,
models,
folderModels: ["collection" as const],
models: models,
folderModels: ["collection"],
icon: "folder",
render: ({ onItemSelect }) => (
<CollectionPicker

View File

@@ -3,31 +3,33 @@
flex-wrap: wrap;
gap: var(--mantine-spacing-xs);
align-items: center;
border: 1px dashed var(--mb-color-border);
background-color: var(--mb-color-bg-light);
border: 1px dashed var(--mb-color-saturated-blue) !important;
background-color: var(--mb-color-background-hover-light);
border-radius: var(--mantine-radius-md);
transition: box-shadow 250ms ease;
min-height: 2.5rem;
min-height: 3.25rem;
}
.max {
cursor: not-allowed;
}
.dragOver {
box-shadow: 0 0 2px 1px var(--mb-color-border);
.dragOver:not(.max) {
box-shadow: 0 0 2px 1px var(--mb-color-saturated-blue);
cursor: none !important;
}
.pill {
display: flex;
flex: 0 0 auto;
padding-inline: 0.5rem;
height: 100%;
font-weight: 400;
background: var(--mb-color-bg-white);
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-xl);
cursor: pointer;
color: var(--mb-color-text-primary);
flex: 0 0 auto;
font-weight: bold;
min-width: 4rem;
text-overflow: ellipsis;
overflow: hidden;
@@ -44,4 +46,5 @@
.inputField {
min-width: 1rem;
height: 2rem;
}

View File

@@ -21,6 +21,7 @@ import { t } from "ttag";
import {
Combobox,
type MantineSize,
Pill,
PillsInput,
ScrollArea,
@@ -37,7 +38,7 @@ interface Props {
onChange: (next: string[]) => void;
maxValues?: number;
placeholder?: string;
size?: string;
size?: MantineSize;
miw?: string | number;
maw?: string | number;
containerId?: string;
@@ -45,6 +46,7 @@ interface Props {
draggedItemId?: string | null;
currentDroppable?: string | null;
"data-testid"?: string;
styles?: { input?: CSSProperties };
}
export function SortablePill({
@@ -53,12 +55,14 @@ export function SortablePill({
onRemove,
containerId,
style,
size,
}: {
id: string;
label: string;
onRemove?: () => void;
containerId?: string;
style?: React.CSSProperties;
style?: CSSProperties;
size?: MantineSize;
}) {
const {
attributes,
@@ -84,6 +88,7 @@ export function SortablePill({
return (
<Pill
size={size}
ref={setNodeRef}
className={cx(S.pill, { [S.dragging]: isDragging })}
withRemoveButton
@@ -93,6 +98,15 @@ export function SortablePill({
{...attributes}
{...listeners}
style={combinedStyle}
styles={{
label: {
display: "flex",
alignItems: "center",
},
remove: {
marginRight: 0,
},
}}
radius="xl"
draggable
onDragStart={(e) => {
@@ -116,6 +130,7 @@ export function ReorderableTagsInput({
draggedItemId,
currentDroppable,
"data-testid": dataTestId,
styles,
}: Props) {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
@@ -192,6 +207,7 @@ export function ReorderableTagsInput({
[S.dragOver]: isDragOver || currentDroppable === containerId,
}),
}}
styles={styles}
onMouseDownCapture={(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
// Do not open when interacting with a pill (likely starting a drag or clicking remove)
@@ -207,6 +223,7 @@ export function ReorderableTagsInput({
onDragOver={(e) => {
// Allow dropping external items
e.preventDefault();
e.stopPropagation();
}}
onDragEnter={(e) => {
const related = e.relatedTarget as Node | null;
@@ -228,6 +245,7 @@ export function ReorderableTagsInput({
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const val = e.dataTransfer.getData("text/plain");
if (!val) {
return;
@@ -252,6 +270,7 @@ export function ReorderableTagsInput({
return (
<SortablePill
size={size}
key={v}
id={v}
label={data.find((o) => o.value === v)?.label ?? v}
@@ -290,6 +309,7 @@ export function ReorderableTagsInput({
removeAt(value.length - 1);
}
}}
style={styles?.input}
/>
</Combobox.EventsTarget>
) : null}

View File

@@ -1,4 +1,5 @@
import { renderWithProviders } from "__support__/ui";
import type { MantineSize } from "metabase/ui";
import { type Option, ReorderableTagsInput } from "./ReorderableTagsInput";
@@ -18,7 +19,7 @@ interface SetupOpts {
onChange?: (value: string[]) => void;
maxValues?: number;
placeholder?: string;
size?: string;
size?: MantineSize;
containerId?: string;
useExternalDnd?: boolean;
draggedItemId?: string | null;

View File

@@ -20,9 +20,12 @@
.DashboardHeaderContainer {
position: relative;
z-index: 2;
border-bottom: 1px solid var(--mb-color-border);
transition: var(--transition-theme-change);
&:not(.noBorder) {
border-bottom: 1px solid var(--mb-color-border);
}
&:not(.isEmbeddingSdk) {
background-color: var(--mb-color-background);
}

View File

@@ -6,7 +6,9 @@ import { DashboardArchivedEntityBanner } from "metabase/archive/components/Archi
import DashboardS from "metabase/css/dashboard.module.css";
import { DashboardHeader } from "metabase/dashboard/components/DashboardHeader";
import { useDashboardContext } from "metabase/dashboard/context";
import { getIsHeaderVisible } from "metabase/dashboard/selectors";
import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import { useSelector } from "metabase/lib/redux";
import { FilterApplyToast } from "metabase/parameters/components/FilterApplyToast";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import { FullWidthContainer } from "metabase/styled-components/layout/FullWidthContainer";
@@ -18,7 +20,6 @@ import {
DashboardInfoButton,
ExportAsPdfButton,
FullscreenToggle,
NightModeToggleButton,
} from "../DashboardHeader/buttons";
import { DashboardParameterPanel } from "../DashboardParameterPanel";
import { DashboardSidebars } from "../DashboardSidebars";
@@ -33,6 +34,8 @@ const DashboardDefaultView = ({ className }: { className?: string }) => {
const { dashboard, isEditing, isFullscreen, isSharing, selectedTabId } =
useDashboardContext();
const isHeaderVisible = useSelector(getIsHeaderVisible);
const currentTabDashcards = useMemo(() => {
if (!dashboard || !Array.isArray(dashboard.dashcards)) {
return [];
@@ -58,6 +61,7 @@ const DashboardDefaultView = ({ className }: { className?: string }) => {
}
const isEmpty = !dashboardHasCards || (dashboardHasCards && !tabHasCards);
const hasTabs = dashboard.tabs && dashboard.tabs.length > 1;
// Embedding SDK has parent containers that requires dashboard to be full height to avoid double scrollbars.
const isFullHeight = isEditing || isSharing || isEmbeddingSdk();
@@ -89,6 +93,7 @@ const DashboardDefaultView = ({ className }: { className?: string }) => {
{
[S.isEmbeddingSdk]: isEmbeddingSdk(),
[S.isFullscreen]: isFullscreen,
[S.noBorder]: !hasTabs && !isHeaderVisible,
},
)}
data-element-id="dashboard-header-container"
@@ -141,7 +146,6 @@ type DashboardComponentType = typeof DashboardDefaultView & {
FullscreenButton: typeof FullscreenToggle;
ExportAsPdfButton: typeof ExportAsPdfButton;
InfoButton: typeof DashboardInfoButton;
NightModeButton: typeof NightModeToggleButton;
RefreshPeriod: typeof RefreshWidget;
};
@@ -154,7 +158,6 @@ DashboardComponent.ParametersList = ParametersList;
DashboardComponent.FullscreenButton = FullscreenToggle;
DashboardComponent.ExportAsPdfButton = ExportAsPdfButton;
DashboardComponent.InfoButton = DashboardInfoButton;
DashboardComponent.NightModeButton = NightModeToggleButton;
DashboardComponent.RefreshPeriod = RefreshWidget;
export const Dashboard = DashboardComponent;

View File

@@ -30,9 +30,9 @@ export const Grid = ({
onRefreshPeriodChange,
isLoadingWithoutCards,
onAddQuestion,
isEditableDashboard,
} = useDashboardContext();
const canWrite = Boolean(dashboard?.can_write);
const currentTabDashcards = useMemo(() => {
if (!dashboard || !Array.isArray(dashboard.dashcards)) {
return [];
@@ -92,7 +92,7 @@ export const Grid = ({
if (isEmpty) {
if (!dashboardHasCards) {
return canWrite ? (
return isEditableDashboard ? (
<DashboardEmptyState
canCreateQuestions={canCreateQuestions}
addQuestion={handleAddQuestion}
@@ -105,7 +105,7 @@ export const Grid = ({
}
if (dashboardHasCards && !tabHasCards) {
return canWrite ? (
return isEditableDashboard ? (
<DashboardEmptyState
canCreateQuestions={canCreateQuestions}
addQuestion={handleAddQuestion}

View File

@@ -75,10 +75,6 @@ const DASHBOARD_EXPECTED_DATA_MAP: Record<
icon: "clock",
tooltip: "Auto-refresh",
},
[DASHBOARD_ACTION.NIGHT_MODE_TOGGLE]: {
icon: "sun",
tooltip: "Switch to dark mode",
},
[DASHBOARD_ACTION.FULLSCREEN_TOGGLE]: {
icon: "expand",
tooltip: "Enter fullscreen",
@@ -338,17 +334,6 @@ describe("DashboardHeaderButtonRow", () => {
expectedButtons: [DASHBOARD_ACTION.FULLSCREEN_TOGGLE],
});
});
it("should show night mode toggle when in fullscreen", async () => {
setup({
isEditing: false,
isFullscreen: true,
});
await expectButtonsToExistInHeader({
expectedButtons: [DASHBOARD_ACTION.NIGHT_MODE_TOGGLE],
});
});
});
describe("when viewing analytics dashboard", () => {

View File

@@ -17,7 +17,6 @@ import {
ExportAsPdfButton,
FullscreenAnalyticsDashboard,
FullscreenToggle,
NightModeToggleButton,
} from "../buttons";
import { AddLinkOrEmbedButton } from "../buttons/AddLinkOrEmbedButton";
@@ -73,11 +72,6 @@ export const dashboardActionButtons: Record<
component: () => <RefreshWidget />,
enabled: ({ dashboard, isEditing }) => !isEditing && !dashboard?.archived,
},
[DASHBOARD_ACTION.NIGHT_MODE_TOGGLE]: {
component: () => <NightModeToggleButton />,
enabled: ({ isEditing, isFullscreen, dashboard }) =>
Boolean(!isEditing && isFullscreen && !dashboard.archived),
},
[DASHBOARD_ACTION.FULLSCREEN_TOGGLE]: {
component: FullscreenToggle,
enabled: ({ isFullscreen, isPublic }) => isPublic || isFullscreen,

View File

@@ -3,7 +3,6 @@ import { DASHBOARD_ACTION } from "./dashboard-action-keys";
// Buttons visible in public dashboard
export const DASHBOARD_DISPLAY_ACTIONS = [
DASHBOARD_ACTION.REFRESH_WIDGET,
DASHBOARD_ACTION.NIGHT_MODE_TOGGLE,
DASHBOARD_ACTION.FULLSCREEN_TOGGLE,
];

View File

@@ -10,7 +10,6 @@ export const DASHBOARD_ACTION = {
EDIT_DASHBOARD: "EDIT_DASHBOARD",
DASHBOARD_SHARING: "DASHBOARD_SHARING",
REFRESH_WIDGET: "REFRESH_WIDGET",
NIGHT_MODE_TOGGLE: "NIGHT_MODE_TOGGLE",
FULLSCREEN_TOGGLE: "FULLSCREEN_TOGGLE",
DASHBOARD_HEADER_ACTION_DIVIDER: "DASHBOARD_HEADER_ACTION_DIVIDER",
DASHBOARD_BOOKMARK: "DASHBOARD_BOOKMARK",

View File

@@ -1,5 +0,0 @@
import { ColorSchemeToggle } from "metabase/common/components/ColorScheme";
export const NightModeToggleButton = () => {
return <ColorSchemeToggle variant="viewHeader" />;
};

View File

@@ -11,5 +11,4 @@ export * from "./EditDashboardButton";
export * from "./FullscreenAnalyticsDashboard";
export * from "./SaveEditButton";
export * from "./FullscreenToggle";
export * from "./NightModeToggleButton";
export * from "./ExportAsPdfButton";

View File

@@ -7,7 +7,7 @@ import { useDashboardTitle } from "metabase/dashboard/hooks/use-dashboard-title"
export const DashboardTitle = ({ className }: { className?: string }) => {
const [title, setTitle] = useDashboardTitle();
const { dashboard } = useDashboardContext();
const { dashboard, isEditableDashboard } = useDashboardContext();
if (!dashboard) {
return null;
@@ -19,7 +19,7 @@ export const DashboardTitle = ({ className }: { className?: string }) => {
key={title}
initialValue={title}
placeholder={t`Add title`}
isDisabled={!dashboard?.can_write}
isDisabled={!isEditableDashboard}
data-testid="dashboard-name-heading"
onChange={setTitle}
maxLength={DASHBOARD_NAME_MAX_LENGTH}

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