Compare commits

...

32 Commits

Author SHA1 Message Date
Nicola Mometto
3ae1585c7e bump 2026-07-17 09:40:23 +01:00
Nicola Mometto
b0b11dca91 test seccomp 2026-07-17 09:39:53 +01:00
Oleh K
8984233763 Align transform job header to fix tab layout shift (#77803)
* Fix layout shift when switching tabs on the transform job page

The job Overview and Run history pages each rendered their own header:
Overview used an editable name input plus a `...` menu, while Run history
used a static title with no menu. The differing heights shifted the tab
row, so the clicked tab jumped when switching between the two.

Extract a shared JobHeader component (and a useJobHeaderState hook for the
readOnly/name-change logic) rendered identically by both pages, mirroring
the MetricHeader pattern. Run history now also shows the `...` menu and the
editable name, and Metabot is consistent across both tabs.

* Unify JobPage header logic with useJobHeaderState

JobPage duplicated the readOnly computation and name-change handler that
now live in useJobHeaderState. Source them from the hook instead; keep the
local useTransformPermissions call only for the page-level loading gate.

* Use strict undefined comparisons in job header code

Address review: job/jobId are never null, so compare with undefined
strictly and derive jobId from job.id in the JobHeader render.
2026-07-17 08:51:59 +02:00
Egor Grushin
8ab2269f04 GDGT-2767 Replace deprecated MaybeLink (#77908)
* Replace deprecated MaybeLink

* refactor
2026-07-17 03:12:48 +01:00
Kevin Kelleher
4385d33640 Fix failing test introduced by #77981 (#78000)
fix test
2026-07-17 01:03:20 +00:00
bryan
64588d02fb test(mfa): auth-mechanism matrix + lockout-corner coverage (#77624) 2026-07-16 22:11:37 +00:00
github-automation-metabase
20b1d51e6b Auto-update documentation for master (#77997)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-16 17:41:49 -04:00
Michael Eby
838d5a7a50 docs: add data isolation content (#77876)
* docs: add data isolation content

* Optimized 4 images, saved 70.0 KB (15.8% smaller)

* Fix links

* Edits from Alex

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-07-16 16:47:42 -04:00
dpsutton
5fb83b02e7 Fix MCP OAuth consent flow when Metabase is hosted under a subpath (#77765)
Fixes GIT-10551: connecting an MCP client to a Metabase instance served
under a subpath (site-url like https://example.com/metabase behind a
prefix-stripping reverse proxy) failed at the consent step.

The OAuth flow already derives every advertised URL from site-url —
discovery metadata, authorization/token/registration endpoints, and the
WWW-Authenticate resource_metadata header are all subpath-correct. But
three browser-facing pieces assumed Metabase lives at the domain root:

1. The consent form posted to root-relative /oauth/authorize/decision,
   so the browser sent the POST outside the subpath and the proxy
   returned 404 (the reported bug). The form action is now the absolute
   site-url-based URL, via the same absolute-url helper the page already
   uses for the logo. The bundled @font-face URLs on the page get the
   same treatment.

2. The CSRF cookie was scoped to Path=/oauth/authorize. Under a subpath
   the consent page lives at /metabase/oauth/authorize, so the browser
   would never send the cookie back and the decision POST would fail
   CSRF validation even with the form action fixed. The cookie path now
   carries the path component of site-url.

3. The logged-out flow: the backend bounces to the login page with a
   basename-relative redirect target (the server sits behind the
   prefix-stripping proxy and never sees the subpath). The frontend
   guard resolved that target against the bare origin, losing the
   subpath on the full-page redirect back to /oauth/authorize. The
   guard now normalizes targets through the router basename in both
   directions: paths without an explicit origin get the basename joined
   (browser-real href), and pathnames get it stripped (router path, so
   the backend-only-path prefix check still matches).

Why this is safe:

- Root deployments are bit-for-bit unchanged. site-url has no path
  component there, so the cookie path is still /oauth/authorize, the
  form action still targets /oauth/authorize/decision, and with an
  empty basename the guard resolves every input shape (relative path,
  bare path, absolute URL) to exactly the URLs it produced before.
- Under a subpath, the only outputs that change are ones that were
  previously unroutable (404 through the proxy or a path the SPA
  router would double-prefix).
- The server never sees the subpath, so no routes, parameters, or
  validation logic change; every fix is to a browser-visible artifact
  derived from site-url.

Tests pin both halves: existing root-deployment assertions are
untouched and still pass, and new tests cover the subpath form action,
cookie path, login redirect target, and the full consent -> decision
round-trip with a subpath site-url. Also verified live against a local
prefix-stripping Caddy proxy with a real MCP client flow (DCR, consent,
PKCE token exchange, tool calls).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:06:24 -05:00
Rodrigo López Dato
31b35fb452 Automatically focus new Key field when adding user attributes (#77715)
* Automatically focus new Key field when adding user attributes

* Only focus new entry if no error
2026-07-16 16:55:13 -03:00
Anderson
f82bdbdd52 Restore table color range saturation for single conditional formatting rules (#77981)
Restore color range saturation for single rules
2026-07-16 16:41:02 -03:00
Alexander Polyankin
2819934fdc Unify image-buffer pool (#77836)
* Rewrite image-buffer pool on dirigiste (same pool as the GraalVM contexts)

Replace the hand-rolled ConcurrentLinkedDeque pool, stats counters, sweeper
daemon, and logging with the dirigiste Pool already used for static-viz
contexts, with the same semantics: 0-3 pooled int[] arrays of the fixed
oversized size, held exclusively per render, idle arrays dropped after ~10
minutes. acquire blocks until an array is free when the requested size fits;
larger requests get a fresh unpooled BufferedImage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extract the dirigiste pool factory into metabase.util.pool

create-pool was duplicated verbatim between the static-viz graal renderer
(channel.render.js.common) and the sqlglot graal runner (sql-parsing.common),
and image-buffer had just picked up a third use. Move it to metabase.util.pool
and use it from all three; the pool behavior tests move from
sql-parsing.common-test to util.pool-test with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add prometheus counters for image-buffer pool usage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rename image-buffer metrics to metabase-notification namespace

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:40:38 -04:00
Timofey Kachalov
ce264b2c85 Update SDK version to 0.64.0-alpha (#77978) 2026-07-16 18:33:44 +00:00
lbrdnk
07f62f33c3 Log suggested prompts generation exception on debug only (#77962)
Log exception on debug only
2026-07-16 18:15:54 +00:00
Andrei Chernykh
adf574b57b Convert Scalar visualization from a class to a function component (#77774) 2026-07-16 11:00:04 -07:00
da
43d61f4514 Update Jetty dependencies to version 12.1.11 (#77860)
https://github.com/jetty/jetty.project/releases

check 12.1.7...12.1.11
2026-07-16 14:08:59 -03:00
Andrei Chernykh
12ce1e67e7 Migrate nav search and search result components from Emotion to Mantine (#77332)
* Migrate nav search and search result components from Emotion to Mantine

* Address review feedback: close button label, valid footer markup, footer role and hover transition

* Restore search dropdown footer ref by returning a ref callback from useListKeyboardNavigation
2026-07-16 17:03:22 +00:00
Rodrigo López Dato
89abda4d66 Browse directly from RCLS settings to saved questions (#77614)
* Browse directly from settings to RCLS saved questions

* Move styles to separate file
2026-07-16 14:02:39 -03:00
Anderson
911fe173f2 Fix margins when printing documents with charts (#74234)
* Improve page/container margins when printing document

* do not call flushSync while printing

* Revert "do not call flushSync while printing"

This reverts commit 37c6fe2b50.

* Resize ECharts synchronously for print layout changes

* Refactor ECharts print resize tests

* Clean up
2026-07-16 13:18:49 -03:00
Ethan McCue
8705016252 Fix gauge breaking in static-viz (#77724)
Fix gauge color crashing

Co-authored-by: Kevin Kelleher <16599994+kevinkelleher12@users.noreply.github.com>
2026-07-16 13:46:56 +00:00
Chris Truter
fc7d1ec13b Make default-source-table-entry deterministic and field-safe (#77725) 2026-07-16 13:09:07 +00:00
da
6108e0182e Accept JWT in the body or in parameters according to the method (#75656)
* Follow up to https://github.com/metabase/metabase/pull/72798#discussion_r3234347346

* Apply suggestions from code review

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

* Update docs/people-and-groups/authenticating-with-jwt.md

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

---------

Co-authored-by: Jeff Bruemmer <jeffbruemmer@gmail.com>
Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2026-07-16 13:01:48 +00:00
Kamil Mielnik
479d7ff67b GDGT-1570 Transform has changes after bluring source table input and still has changes after cancelling them (#77712) 2026-07-16 18:20:13 +07:00
Nicola Mometto
3e6612f075 fix incremental transforms on clickhouse (#77921)
* remove compile-with-inline for clickhouse, no longer needed since the driver got fixed

* re-enable full tests

* fix: for max behavior on clickhouse

* fix: insert for clickhouse

* add temporal reg test
2026-07-16 12:04:46 +01:00
Kamil Mielnik
ce8a99bf08 GDGT-2648 Action menu is too wide when editing a dashboard (#77795) 2026-07-16 17:43:14 +07:00
Nicola Mometto
ed3aa72e65 humanize the alert management error when recipient input is wrong (#77852)
* fix: better human readable validation

* tests
2026-07-16 10:34:25 +00:00
Romeo Van Snick
4081105624 Migrate expression parser and NotebookStepPreview to TypeScript (#77914)
* Generate expression parser as TypeScript

Add --typeScript to lezer-generator so the parser emits typed output; convert lezer.js to lezer.ts.

* Migrate NotebookStepPreview to TypeScript

* Reuse QuestionResultLoaderChildrenProps for VisualizationPreview props
2026-07-16 19:04:36 +10:00
Nicola Mometto
37d96787a1 Better human validation of notification schemas (#77922)
* fix: tighten schema

* 400 rather than 500
2026-07-16 08:57:22 +00:00
Fraser
e823ccc4d7 Move source-location utils to their sole consumer, metabot (#77676) 2026-07-16 20:33:45 +12:00
Romeo Van Snick
4674a3610f Compose route guards as element wrappers (#77686)
* Add element prop support to facade Route

* Compose route guards as element wrappers

* Memoize the element bridge by component type and render off the injected route

* Clarify that index routes accept component or element

* Test that the element bridge reuses shared components across sibling routes
2026-07-16 17:32:12 +10:00
Tyler Plude
1f0d8d6b34 [BOT-1840] Drop visualizer bespoke search index cols (#77684)
* drop temporal dim related search index cols

* revert manual changes to generated docs

* pr feedback; bump up overfetch in visualizer

* rm stale test
2026-07-15 22:02:32 -07:00
Chris Truter
5616e61f7a Allow obsolete semantic search kill-switch (#77903) 2026-07-15 21:58:06 -07:00
159 changed files with 2797 additions and 1829 deletions

View File

@@ -3564,6 +3564,7 @@
config
connection-pool
events
lib-be
llm
metabot
models

View File

@@ -19,7 +19,8 @@ while [[ $# -gt 0 ]]; do
done
echo "Cloning python-runner repository..."
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git --branch seccomp python-runner
if [ "$SKIP_START" = false ]; then
echo "Starting python-runner..."

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -161,10 +161,10 @@
org.clojure/tools.namespace {:mvn/version "1.5.0"}
org.clojure/tools.reader {:mvn/version "1.5.2"}
org.clojure/tools.trace {:mvn/version "0.8.0"} ; function tracing
org.eclipse.jetty/jetty-server {:mvn/version "12.1.7"}
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.1.7"}
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.1.7"}
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.1.7"}
org.eclipse.jetty/jetty-server {:mvn/version "12.1.11"}
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.1.11"}
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.1.11"}
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.1.11"}
org.eclipse.jgit/org.eclipse.jgit {:mvn/version "7.3.0.202506031305-r"}; Java implementation of Git version control system
org.flatland/ordered {:mvn/version "1.15.12"} ; ordered maps & sets
org.graalvm.polyglot/js-community {:mvn/version "25.0.1" :extension "pom"} ; JavaScript engine

82
docs/api.json generated
View File

@@ -5033,7 +5033,13 @@
}
},
"required" : [ "channel_type", "details" ]
}, { } ]
}, {
"type" : "object",
"properties" : {
"channel_type" : { }
},
"required" : [ "channel_type" ]
} ]
},
"metabase.channel.models.channel.ChannelTemplateEmailDetails" : {
"oneOf" : [ {
@@ -5102,7 +5108,13 @@
}
},
"required" : [ "channel_type", "details" ]
}, { } ]
}, {
"type" : "object",
"properties" : {
"channel_type" : { }
},
"required" : [ "channel_type" ]
} ]
},
"metabase.collections-rest.api.DashboardQuestionCandidate" : {
"type" : "object",
@@ -37214,7 +37226,7 @@
"post" : {
"operationId" : "post-api-measure",
"summary" : "POST /api/measure",
"description" : "Create a new `Measure`.",
"description" : "Create a new `Measure`. The Measure's table is derived from its `definition`.",
"parameters" : [ ],
"responses" : {
"2XX" : {
@@ -37255,14 +37267,9 @@
"name" : {
"type" : "string",
"minLength" : 1
},
"table_id" : {
"description" : "value must be an integer greater than zero.",
"type" : "integer",
"minimum" : 1
}
},
"required" : [ "name", "table_id", "definition" ]
"required" : [ "name", "definition" ]
}
}
}
@@ -44356,7 +44363,7 @@
"get" : {
"operationId" : "get-api-search",
"summary" : "GET /api/search",
"description" : "Search for items in Metabase.\n For the list of supported models, check [[metabase.search.config/all-models]].\n\n `context` identifies the surface issuing the search; it selects the ranking weights and filter defaults.\n It defaults to `api`, the value for programmatic callers.\n\n Filters:\n - `archived`: set to true to search archived items only, default is false\n - `table_db_id`: search for tables, cards, and models of a certain DB\n - `models`: only search for items of specific models. If not provided, search for all models\n - `filter_items_in_personal_collection`: only search for items in personal collections\n - `created_at`: search for items created at a specific timestamp\n - `created_by`: search for items created by a specific user\n - `last_edited_at`: search for items last edited at a specific timestamp\n - `last_edited_by`: search for items last edited by a specific user\n - `search_native_query`: set to true to search the content of native queries\n - `vector_search_strategy`: for the semantic engine: `hnsw` (approximate index search, default),\n `brute-force` (exact filter-first search), or `hnsw-iterative-relaxed` / `hnsw-iterative-strict`\n (index-backed iterative scans with inline filters); ignored by other engines\n - `vector_search_ef_search`: override pgvector's `hnsw.ef_search` (1-1000) for the iterative strategies; (admin only)\n - `vector_search_max_scan_tuples`: override pgvector's `hnsw.max_scan_tuples` for the iterative strategies; (admin only)\n - `vector_search_explain`: set to true to record vector-scan instrumentation for this search (expensive); (admin only)\n - `verified`: set to true to search for verified items only (requires Content Management or Official Collections premium feature)\n - `ids`: search for items with those ids, works iff single value passed to `models`\n - `display_type`: search for cards/models with specific display types\n - `non_temporal_dim_ids`: search for cards/metrics/datasets with this exact set of non temporal dimension field IDs (requires appdb engine)\n - `has_temporal_dim`: set to true for cards/metrics/datasets with 1 or more temporal dimensions (requires appdb engine)\n\n Note that not all item types support all filters, and the results will include only models that support the provided\n filters. For example:\n\n - The `created-by` filter supports dashboards, models, actions, and cards.\n - The `verified` filter supports models and cards.\n\n A search query that has both filters applied will only return models and cards.",
"description" : "Search for items in Metabase.\n For the list of supported models, check [[metabase.search.config/all-models]].\n\n `context` identifies the surface issuing the search; it selects the ranking weights and filter defaults.\n It defaults to `api`, the value for programmatic callers.\n\n Filters:\n - `archived`: set to true to search archived items only, default is false\n - `table_db_id`: search for tables, cards, and models of a certain DB\n - `models`: only search for items of specific models. If not provided, search for all models\n - `filter_items_in_personal_collection`: only search for items in personal collections\n - `created_at`: search for items created at a specific timestamp\n - `created_by`: search for items created by a specific user\n - `last_edited_at`: search for items last edited at a specific timestamp\n - `last_edited_by`: search for items last edited by a specific user\n - `search_native_query`: set to true to search the content of native queries\n - `vector_search_strategy`: for the semantic engine: `hnsw` (approximate index search, default),\n `brute-force` (exact filter-first search), or `hnsw-iterative-relaxed` / `hnsw-iterative-strict`\n (index-backed iterative scans with inline filters); ignored by other engines\n - `vector_search_ef_search`: override pgvector's `hnsw.ef_search` (1-1000) for the iterative strategies; (admin only)\n - `vector_search_max_scan_tuples`: override pgvector's `hnsw.max_scan_tuples` for the iterative strategies; (admin only)\n - `vector_search_explain`: set to true to record vector-scan instrumentation for this search (expensive); (admin only)\n - `verified`: set to true to search for verified items only (requires Content Management or Official Collections premium feature)\n - `ids`: search for items with those ids, works iff single value passed to `models`\n - `display_type`: search for cards/models with specific display types\n\n Note that not all item types support all filters, and the results will include only models that support the provided\n filters. For example:\n\n - The `created-by` filter supports dashboards, models, actions, and cards.\n - The `verified` filter supports models and cards.\n\n A search query that has both filters applied will only return models and cards.",
"parameters" : [ {
"in" : "query",
"name" : "q",
@@ -44658,29 +44665,6 @@
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "non_temporal_dim_ids",
"required" : false,
"schema" : {
"oneOf" : [ {
"type" : "string",
"minLength" : 1
}, {
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "has_temporal_dim",
"required" : false,
"schema" : {
"oneOf" : [ {
"type" : "boolean"
}, {
"type" : "null"
} ]
}
} ],
"responses" : {
"2XX" : {
@@ -45002,29 +44986,6 @@
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "non_temporal_dim_ids",
"required" : false,
"schema" : {
"oneOf" : [ {
"type" : "string",
"minLength" : 1
}, {
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "has_temporal_dim",
"required" : false,
"schema" : {
"oneOf" : [ {
"type" : "boolean"
}, {
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "expected_result_type",
@@ -45173,7 +45134,7 @@
"post" : {
"operationId" : "post-api-segment",
"summary" : "POST /api/segment",
"description" : "Create a new `Segment`.",
"description" : "Create a new `Segment`. The Segment's table is derived from its `definition`.",
"parameters" : [ ],
"responses" : {
"2XX" : {
@@ -45207,14 +45168,9 @@
"name" : {
"type" : "string",
"minLength" : 1
},
"table_id" : {
"description" : "value must be an integer greater than zero.",
"type" : "integer",
"minimum" : 1
}
},
"required" : [ "name", "table_id", "definition" ]
"required" : [ "name", "definition" ]
}
}
}

View File

@@ -281,6 +281,8 @@ app.get("/sso/metabase", async (req, res) => {
});
```
For modular embedding, Metabase exchanges the JWT via `POST /auth/sso` with a JSON body (not a `GET` redirect). Full app embedding typically uses the `GET` redirect shown above. See [JWT-based authentication](../people-and-groups/authenticating-with-jwt.md).
## Embedding Metabase in a different domain
{% include plans-blockquote.html feature="Authenticated modular embeds" convert_pro_link_to_embedding=true is_plural=true %}

View File

@@ -109,6 +109,8 @@ If you're using [JWT](../people-and-groups/authenticating-with-jwt.md), you can
https://metabase.example.com/auth/sso?jwt=<token>&return_to=%2Fdashboard%2F1
```
To avoid putting the JWT in the URL, you can also authenticate with a `POST` request and a JSON body. See [JWT-based authentication](../people-and-groups/authenticating-with-jwt.md).
You must URL encode (or double encode, depending on your web setup) all of the parameters in your redirect link, including parameters for filters (e.g., `filter=value`) and [UI settings](#showing-or-hiding-metabase-ui-components) (e.g., `top_nav=true`). For example, if you added two filter parameters to the JWT example shown above, your `src` link would become:
```

View File

@@ -197,6 +197,8 @@ See [Modular embedding parameters](./parameters.md#pass-parameter-values-to-embe
Locked parameters let you filter data without exposing the filter to the end-user. Locking parameters is useful for restricting data based on who's viewing the embed (for example, showing each customer only their own data).
![Locked parameters](./images/locked-parameters.png)
To use locked parameters, you need to:
1. Set the parameter to **Locked** in the embed settings.

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -308,6 +308,8 @@ For an overview of how data permissions work in Metabase, see [Data permissions]
If every tenant has their data in a separate database, then instead of using permissions for data access control, you can use [**database routing**](../permissions/database-routing.md) to route queries to appropriate databases directly.
For a comparison of data isolation methods, see [Choosing a data isolation method](../permissions/data-isolation-methods.md).
- **Create queries** controls whether tenant users can create queries on the data they see. If you want to give your tenant users the ability to drill through (e.g., through `drills` parameter in [modular embedding](../embedding/modular-embedding.md)), you need to give them "Create queries" permissions, because a drill through is a new query.
- **Download results** controls, unsurprisingly, whether people can download results of queries. You need to set download permissions if you want to give your users the option to download their data as a spreadsheet (for example, through `with-downloads` parameter in [modular embedding](../embedding/modular-embedding.md)).
@@ -437,3 +439,4 @@ If you disable multi-tenant strategy, _all your tenant users will be deactivated
- [Embedding overview](./start.md)
- [JWT authentication](../people-and-groups/authenticating-with-jwt.md)
- [Permissions overview](../permissions/start.md)
- [Data isolation methods](../permissions/data-isolation-methods.md)

View File

@@ -22,7 +22,30 @@ Assuming your site is localhost serving on port 3000:
5. In the event of a successful sign-in, your authentication app should issue a GET request to your Metabase endpoint with the token and the "return to" URI: `http://localhost:3000/auth/sso?jwt=TOKEN_GOES_HERE&return_to=/question/1-superb-question`.
6. Metabase verifies the JSON Web Token, logs the person in, then redirects the person to their original destination, `/question/1-superb-question`.
As an alternative to putting the JWT in the URL query string (which some environments log or cache), you can send the same token in the body of a `POST` request to `/auth/sso` with `Content-Type: application/json` and a JSON body like `{"jwt": "TOKEN_GOES_HERE"}`. You can still pass `return_to` as a query parameter on that URL (for example `POST /auth/sso?return_to=/question/1-superb-question`). The login behavior is otherwise the same as the GET flow.
- For full app embeds, use a `GET` request.
- For modular embeds use a `POST` request with a JSON body (to avoid putting the JWT in the URL). You'll also want to use `POST` requests in contexts that log or cache URLs, such as server-side integrations or testing.
In both cases the login behavior is the same, and you can pass `return_to` as a query parameter in both `GET` and `POST` requests.
An example `POST` request with curl:
```bash
curl -X POST "http://localhost:3000/auth/sso?return_to=/question/1-superb-question" \
-H "Content-Type: application/json" \
-d '{"jwt": "TOKEN_GOES_HERE"}'
```
Or with JavaScript:
```js
await fetch(`${METABASE_URL}/auth/sso?return_to=/question/1-superb-question`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jwt: token }),
credentials: "include",
});
```
## Set up JWT authentication

View File

@@ -0,0 +1,90 @@
---
title: Data isolation methods
summary: Compare the ways Metabase can restrict data access for internal analytics or multi-tenant setups, including row and column security, impersonation, database routing, and locked parameters.
---
# Data isolation methods
> Row and column security, impersonation, and database routing are only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Metabase offers several ways to ensure people only see the data they're supposed to see, whether you're restricting data by team for internal analytics or isolating customer data in a multi-tenant setup. The best method depends on how you store your data and where you want to enforce access rules:
- If all data lives in the same tables, use [row and column security](#row-and-column-security) to filter data in Metabase, or [impersonation](#impersonation) to enforce access rules at the database level.
- If each customer or team has their own database, use [database routing](#database-routing).
- If you're embedding dashboards for people without Metabase accounts, use [locked parameters](#locked-parameters).
You can combine these methods. For example, you can use locked parameters for a guest embed while using row and column security for signed-in users.
## Row and column security
[Row and column security](./row-and-column-security.md) lets you control which rows and columns each group can see. You can define rules that filter data based on [user attributes](../people-and-groups/managing.md#adding-a-user-attribute). Metabase applies your rules to questions you build in the query builder.
![Row and column security](./images/row-and-column-level-security.png)
Use row and column security when:
- You need granular control over which rows and columns each group or customer can see.
- You want to manage access rules in the Metabase UI instead of in your database.
Don't use row and column security when:
- People need to write native SQL queries. Row and column security doesn't apply to the SQL editor.
- You need column-level security on MongoDB or Druid, which only support row-level security.
## Impersonation
[Impersonation](./impersonation.md) lets your database control what each person can see. You define roles and row-level security policies in your database. Metabase connects using the role specified by each person's [user attribute](../people-and-groups/managing.md#adding-a-user-attribute), and your database enforces what that role can see.
![Impersonation](./images/connection-impersonation.png)
Use impersonation when:
- You want to centralize access logic in your database instead of in Metabase.
- People need to write native SQL queries. Since your database enforces the rules, the rules apply in the SQL editor too.
Don't use impersonation when:
- Your database [doesn't support it](./impersonation.md#databases-that-support-impersonation).
- You don't want to set up and maintain roles and policies in your database.
- You rely heavily on caching. You can't share cached results between roles.
## Database routing
[Database routing](./database-routing.md) sends each person's queries to their own database. You build questions and dashboards once against a primary database, and Metabase routes queries to the right destination database based on each person's [user attribute](../people-and-groups/managing.md#adding-a-user-attribute). Database routing requires that each group's data lives in a separate database, and that all destination databases have identical schemas.
![Database routing](./images/database-routing-diagram.png)
Use database routing when:
- You want the strongest isolation. Each group's data lives in a separate database.
- You want to avoid one group's queries affecting the performance of another group's queries.
Don't use database routing when:
- Groups share tables. Use [row and column security](#row-and-column-security) or [impersonation](#impersonation) instead.
- You want to manage access rules in the Metabase UI instead of maintaining a separate database for each group.
## Locked parameters
[Locked parameters](../embedding/guest-embedding.md#locked-parameters) filter an embedded dashboard or question before Metabase displays it. Your app passes the filter value in a signed JWT. The filter is invisible to the person viewing the embed, and they can't change it.
![Locked parameters](../embedding/images/locked-parameters.png)
Use locked parameters when:
- You want to restrict data without setting up Metabase accounts, user attributes, or permissions.
- You want to filter data without exposing the filter or its values to the people viewing the embed.
Don't use locked parameters when:
- You want account-level or role-based access control across your Metabase.
- You're working with sensitive data. Use a method that enforces access at the permissions or database level instead, like [row and column security](#row-and-column-security), [impersonation](#impersonation), or [database routing](#database-routing).
## Further reading
- [Permissions overview](./start.md)
- [Tenants](../embedding/tenants.md)
- [Row and column security](./row-and-column-security.md)
- [Impersonation](./impersonation.md)
- [Database routing](./database-routing.md)
- [Locked parameters for guest embeds](../embedding/guest-embedding.md#locked-parameters)

View File

@@ -9,6 +9,8 @@ summary: Route queries to different databases based on who's viewing them. Great
With database routing, an admin can build a question once using one database, and the question will run its query against a different database with the same schema depending on who is viewing the question.
![Database routing diagram](./images/database-routing-diagram.png)
Database routing is useful for:
- Managing modular and full app embedding setups where each customer has their own database with identical schemas.

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -10,6 +10,8 @@ This page covers the [View data](./data.md#view-data-permissions) permission lev
**Impersonation access** allows admins to "outsource" View data permissions to roles in your database. Admins can associate user attributes with database-defined roles and their privileges. If someone is in a group with their View data permission set to Impersonation, the person will be able to view and query data based on the privileges granted to the role specified by their user attribute.
![Connection impersonation](./images/connection-impersonation.png)
## Databases that support impersonation
For now, impersonation access is only available for the following databases:

View File

@@ -14,6 +14,8 @@ Row and column security lets you give granular permissions for different groups
You can use row and column security to set up [self-service analytics](https://www.metabase.com/learn/metabase-basics/embedding/multi-tenant-self-service-analytics), so that each of your customers can only view the rows that match their customer ID. For example, if you have an Accounts table with information about your customers, you can add permissions to the table so that each customer only sees the data relevant to them.
![Row and column security](./images/row-and-column-level-security.png)
> Row and column security was formerly called data sandboxing. It's the same feature, it just now has a more descriptive name.
## Row and column security examples

View File

@@ -28,6 +28,10 @@ Granting group access to different Metabase features.
Setting up row and column security to restrict access to rows and columns in tables.
## [Choosing a data isolation method](./data-isolation-methods.md)
Compare row and column security, impersonation, database routing, and locked parameters for multi-tenant setups.
## [Snippet folder permissions](./snippets.md)
Organize snippets into folders that require permissions to view.

View File

@@ -1665,3 +1665,37 @@ describe("issue 63416", () => {
});
});
});
describe("issue 76056", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.setActionsEnabledForDB(SAMPLE_DB_ID);
H.createDashboard().then(({ body: dashboard }) => {
H.visitDashboard(dashboard.id);
});
});
it("the dashcard actions panel should not have redundant horizontal space (metabase#76056)", () => {
H.editDashboard();
cy.button("Add action").click();
cy.button("Close").click();
H.showDashboardCardActions();
cy.findByTestId("dashboardcard-actions-panel")
.should("be.visible")
.then(($panel) => {
const panelRect = $panel[0].getBoundingClientRect();
const buttonRects = Array.from($panel[0].querySelectorAll("a")).map(
(button) => button.getBoundingClientRect(),
);
const maxRight = Math.max(...buttonRects.map((rect) => rect.right));
const minLeft = Math.min(...buttonRects.map((rect) => rect.left));
const contentWidth = maxRight - minLeft;
expect(panelRect.width).to.be.closeTo(contentWidth, 10);
});
});
});

View File

@@ -25,6 +25,7 @@
[metabase.sso.core :as sso]
[metabase.util.encryption :as encryption]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[metabase.util.malli.schema :as ms]
[metabase.util.password :as u.password]
[throttle.core :as throttle]
@@ -67,9 +68,15 @@
(t2/select-one-fn :credentials :model/AuthIdentity :user_id user-id :provider "password")]
(and password_hash (u.password/verify-password password password_salt password_hash)))
(when (sso/ldap-enabled)
(when-let [user-email (t2/select-one-fn :email :model/User user-id)]
(when-let [user-info (sso/find-user user-email)]
(sso/verify-password user-info password))))))))
;; an unreachable directory fails closed: re-auth is denied (the user retries when the
;; directory is back; admin/remove needs no re-auth), never an unhandled 500
(try
(when-let [user-email (t2/select-one-fn :email :model/User user-id)]
(when-let [user-info (sso/find-user user-email)]
(sso/verify-password user-info password)))
(catch Exception e
(log/warn e "LDAP re-auth failed because the directory is unreachable")
false)))))))
;; Notification emails here are fire-and-log by construction: the messages/send-mfa-*-email!
;; senders route through email/send-message!, which catches and logs delivery failures — so an
@@ -164,14 +171,23 @@
;;; -------------------------------------------------- Admin --------------------------------------------------
(api.macros/defendpoint :post "/admin/remove" :- nil
"Admin: remove a user's two-factor enrollment entirely — the lockout escape hatch for a lost
authenticator with no recovery codes. Never feature-gated (a lapsed license must not make
lockouts permanent). The affected user is notified by email. They re-enroll from scratch —
there is nothing to \"reset\", the secret lives on their device."
"Admin: remove *another* user's two-factor enrollment entirely — the lockout escape hatch for a
lost authenticator with no recovery codes. Never feature-gated (a lapsed license must not make
lockouts permanent). The affected user is notified by email. They re-enroll from scratch — there
is nothing to \"reset\", the secret lives on their device.
Removing your *own* enrollment is deliberately refused here: this is a user-management endpoint
that takes no second factor (the target can't produce one — that's why an admin is removing it),
and the admin UI never collects a code. Self-removal goes through the normal Security page, which
re-auths with a fresh factor via `/disable`. Without this guard a hijacked admin session could
strip its own 2FA with only a cookie, turning transient access into a permanent password bypass."
[_route-params
_query-params
{user-id :user_id} :- [:map [:user_id ms/PositiveInt]]]
(api/check-superuser)
(when (= user-id api/*current-user-id*)
(throw (ex-info (tru "You cannot administratively remove your own two-factor authentication. Please use the normal removal method in your account settings.")
{:status-code 400})))
(when (enrollment/disable! user-id)
(let [user (t2/select-one :model/User :id user-id)]
(messages/send-mfa-removed-by-admin-email! (:email user))

View File

@@ -6,6 +6,7 @@
[clojurewerkz.quartzite.triggers :as triggers]
[metabase-enterprise.semantic-search.core :as semantic-search.core]
[metabase-enterprise.semantic-search.util :as semantic.u]
[metabase.lib-be.core :as lib-be]
[metabase.search.ingestion :as search.ingestion]
[metabase.task.core :as task]
[metabase.util.log :as log])
@@ -24,7 +25,10 @@
(log/with-context {:quartz-job-type 'SemanticIndexRepair}
(try
(log/info "Starting semantic search index repair")
(semantic-search.core/repair-index! (search.ingestion/searchable-documents))
;; The document eduction is realized inside repair-index!, so the metadata-provider cache must be
;; bound around the whole call for spec :fn attrs that touch metadata to benefit from it.
(lib-be/with-metadata-provider-cache
(semantic-search.core/repair-index! (search.ingestion/searchable-documents)))
(log/info "Completed semantic search index repair")
(catch Exception e
(log/error e "Failed to complete semantic search index repair"))))))

View File

@@ -4,10 +4,12 @@
[metabase.util.i18n :refer [tru]]))
(defn request-jwt
"JWT from the request `:params` (which includes both query string and form body) or JSON body (`:body`), in that order."
"JWT from the SSO request. GET reads `:params` (query string). POST reads JSON `:body` only."
[req]
(or (get-in req [:params :jwt])
(get-in req [:body :jwt])))
(case (:request-method req)
:get (get-in req [:params :jwt])
:post (get-in req [:body :jwt])
nil))
(defn- select-sso-backend
[req]

View File

@@ -5,7 +5,8 @@
[metabase-enterprise.mfa.enrollment :as enrollment]
[metabase-enterprise.mfa.totp :as totp]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]))
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db :web-server :test-users))
@@ -22,6 +23,23 @@
(mt/user-http-request :crowberto :post 204 "ee/mfa/admin/remove" {:user_id user-id})
(is (nil? (enrollment/enrolled-method user-id)))))))
(deftest admin-cannot-self-remove-test
;; admin/remove is "manage OTHER users" — self-removal is refused so a hijacked admin cookie
;; can't strip its own 2FA (this endpoint takes no factor; a stolen session has none to give).
;; Removing your own enrollment goes through /disable, which re-auths with a fresh code.
(mt/with-temp [:model/AuthIdentity _ {:user_id (mt/user->id :crowberto)
:provider "totp"
:confirmed_at (t/instant)
:credentials {:secret (totp/generate-secret)}}]
(try
(testing "removing your own id is refused, enrollment intact"
(is (re-find #"cannot administratively remove your own"
(mt/user-http-request :crowberto :post 400 "ee/mfa/admin/remove"
{:user_id (mt/user->id :crowberto)})))
(is (= :totp (enrollment/enrolled-method (mt/user->id :crowberto)))))
(finally
(t2/delete! :model/AuthIdentity :user_id (mt/user->id :crowberto) :provider "totp")))))
(deftest admin-remove-is-audited-test
;; audit rows require the :audit-app feature at event time (premium-features/log-enabled?)
(mt/with-premium-features #{:audit-app}

View File

@@ -68,6 +68,81 @@
(is (true? (:mfa/pending? gated)))
(is (nil? (:mfa/first-factor gated)))))))))))
(deftest gate-sso-provider-matrix-test
(testing "every SSO provider is exempt for an enrolled user — the IdP owns MFA there"
(mt/with-premium-features #{:multi-factor-auth}
(mt/with-temporary-setting-values [mfa-enforcement :optional]
(with-enrolled-user!
(fn [user-id]
(let [result {:success? true :user {:id user-id}}]
(doseq [provider [:provider/google
:provider/saml
:provider/jwt
:provider/oidc
:provider/custom-oidc
:provider/slack-connect]]
(testing provider
(is (= result (auth-identity.provider/apply-mfa-gate provider result))))))))))))
(deftest gate-provider-registry-exhaustiveness-test
;; The gate's fall-through branch is exempt, so a provider nobody classified silently bypasses
;; MFA. This test forces the decision: every provider in the auth-identity hierarchy must appear
;; below. If you added a provider and landed here, decide its MFA posture — challenged providers
;; also go in `metabase-enterprise.mfa.gate/challenged-providers`, session-suppressed ones in
;; `session-suppressed-providers`; exemption is the default and needs only this map.
(let [classification {:provider/password :challenged
:provider/ldap :challenged
:provider/emailed-secret-password-reset :session-suppressed
;; IdP owns MFA for SSO logins
:provider/google :exempt
:provider/saml :exempt
:provider/jwt :exempt
:provider/oidc :exempt
:provider/custom-oidc :exempt
:provider/slack-connect :exempt
;; admin-granted, time-boxed, audited; staff can't hold the second factor
:provider/support-access-grant :exempt
;; abstract parent (reset/verification/magic links) — a concrete login
;; child must be classified here on its own when it ships
:provider/emailed-secret :exempt
;; the second factor itself, never a first factor
:provider/totp :exempt}]
(testing "every registered provider has a deliberate MFA classification"
(is (= (set (keys classification))
(set (descendants :metabase.auth-identity.provider/provider)))))
(testing "the gate's behavior matches each classification for an enrolled user"
(mt/with-premium-features #{:multi-factor-auth}
(mt/with-temporary-setting-values [mfa-enforcement :optional]
(with-enrolled-user!
(fn [user-id]
(let [result {:success? true :user {:id user-id}}]
(doseq [[provider posture] classification]
(testing provider
(let [gated (auth-identity.provider/apply-mfa-gate provider result)]
(case posture
:challenged (is (= :mfa-required (:success? gated)))
:session-suppressed (do (is (true? (:success? gated)))
(is (true? (:mfa/pending? gated))))
:exempt (is (= result gated))))))))))))))
(deftest gate-env-var-escape-hatch-test
;; The last-admin lockout recovery: the only superuser loses authenticator + recovery codes, so
;; nobody can log in to flip the setting. Restarting with MB_MFA_ENFORCEMENT=off must win over
;; the :optional stored in the DB and let them in single-step.
(mt/with-premium-features #{:multi-factor-auth}
(mt/with-temporary-setting-values [mfa-enforcement :optional]
(with-enrolled-user!
(fn [user-id]
(let [result {:success? true :user {:id user-id}}]
(is (= :mfa-required
(:success? (auth-identity.provider/apply-mfa-gate :provider/password result)))
"sanity: challenged before the override")
(mt/with-temp-env-var-value! [mb-mfa-enforcement "off"]
(is (false? (mfa.settings/mfa-enabled?))
"the env var beats the DB value")
(is (= result (auth-identity.provider/apply-mfa-gate :provider/password result))
"the gate no-ops, so the locked-out admin can log in"))))))))
(deftest mfa-enforcement-write-path-test
(mt/with-temporary-setting-values [mfa-enforcement :off]
(testing "enabling (:optional) requires the :multi-factor-auth feature (setup is gated; enforcement is not)"

View File

@@ -5,9 +5,11 @@
(:require
[clojure.test :refer :all]
[java-time.api :as t]
[metabase-enterprise.mfa.enrollment :as enrollment]
[metabase-enterprise.mfa.management :as mfa.management]
[metabase-enterprise.mfa.totp :as totp]
[metabase.session.api :as api.session]
[metabase.sso.core :as sso]
[metabase.sso.ldap-test-util :as ldap.test]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
@@ -73,6 +75,35 @@
(testing "the enroll endpoint's schema also refuses a blank password"
(mt/client session-key :post 400 "ee/mfa/enroll" {:password ""})))))))
(deftest ldap-directory-unreachable-fails-closed-test
;; Lockout-corner: the directory goes down while a user is enrolled. Re-auth (disable) must fail
;; closed as a denied verification — not a 500 — and must not touch the enrollment. The recovery
;; path that needs no directory, admin/remove, keeps working.
(mt/with-premium-features #{:multi-factor-auth}
(mt/with-temporary-setting-values [mfa-enforcement :optional]
(ldap.test/with-ldap-server!
(let [{session-key :id} (mt/client :post 200 "session" {:username sally-email
:password sally-directory-password})
user-id (t2/select-one-fn :id :model/User :email sally-email)
secret (totp/generate-secret)]
(try
(t2/insert! :model/AuthIdentity {:user_id user-id
:provider "totp"
:confirmed_at (t/instant)
:credentials {:secret secret}})
(with-redefs [sso/find-user (fn [& _] (throw (ex-info "connection refused" {})))]
(testing "enroll re-auth with the directory down is a 400, not a 500"
(is (=? {:errors {:password some?}}
(mt/client session-key :post 400 "ee/mfa/enroll"
{:password sally-directory-password}))))
(testing "the enrollment is untouched"
(is (= :totp (enrollment/enrolled-method user-id))))
(testing "admin/remove needs no directory and still recovers the account"
(mt/user-http-request :crowberto :post 204 "ee/mfa/admin/remove" {:user_id user-id})
(is (nil? (enrollment/enrolled-method user-id)))))
(finally
(t2/delete! :model/AuthIdentity :user_id user-id :provider "totp"))))))))
(deftest ldap-only-user-enrolls-with-directory-password-test
(mt/with-premium-features #{:multi-factor-auth}
(mt/with-temporary-setting-values [mfa-enforcement :optional]

View File

@@ -2,6 +2,7 @@
(:require
[clojure.test :refer :all]
[java-time.api :as t]
[metabase-enterprise.mfa.enrollment :as enrollment]
[metabase-enterprise.mfa.recovery-codes :as recovery-codes]
[metabase-enterprise.mfa.totp :as totp]
[metabase-enterprise.mfa.verification :as verification]
@@ -194,3 +195,25 @@
(t2/select-one-fn :credentials :model/AuthIdentity
:user_id user-id :provider "totp"))
"sanity: the old key can no longer read the row"))))))
(deftest lost-encryption-key-fails-closed-test
(testing "an instance started with the wrong key: verify errors (no bypass, no session), and
admin removal — which never decrypts — still recovers the account"
(let [secret (totp/generate-secret)
k1 "0123456789abcdef-key-A"
k2 "totally-different-key-B!"]
(mt/with-temp [:model/User {user-id :id} {}]
(encryption-test/with-secret-key k1
(t2/insert! :model/AuthIdentity {:user_id user-id
:provider "totp"
:confirmed_at (t/instant)
:credentials {:secret secret}}))
(encryption-test/with-secret-key k2
(testing "verification fails closed — an error, never a false-positive login"
(is (thrown? Exception
(verification/verify-attempt! user-id (totp/generate-code secret) (fresh-jti)))))
(testing "the enrollment row survives the failed attempt"
(is (= 1 (t2/count :auth_identity :user_id user-id :provider "totp"))))
(testing "disable! deletes without decrypting, so the admin escape hatch works"
(is (true? (enrollment/disable! user-id)))
(is (zero? (t2/count :auth_identity :user_id user-id :provider "totp")))))))))

View File

@@ -4,6 +4,7 @@
[buddy.sign.util :as buddy-util]
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.sso.api.interface :as sso.i]
[metabase-enterprise.sso.integrations.token-utils :as token-utils]
[metabase-enterprise.sso.settings :as sso-settings]
[metabase-enterprise.sso.test-setup :as sso.test-setup]
@@ -234,6 +235,21 @@
(= {"extra" "keypairs", "are" "also present"}
(t2/select-one-fn :jwt_attributes :model/User :email "rasta@metabase.com"))))))))
(deftest request-jwt-test
(let [token "some.jwt.token"]
(testing "GET reads jwt from query params"
(is (= token (sso.i/request-jwt {:request-method :get :params {:jwt token}}))))
(testing "GET ignores jwt in body"
(is (nil? (sso.i/request-jwt {:request-method :get :body {:jwt token}}))))
(testing "POST reads jwt from JSON body"
(is (= token (sso.i/request-jwt {:request-method :post :body {:jwt token}}))))
(testing "POST ignores jwt in query params"
(is (nil? (sso.i/request-jwt {:request-method :post :params {:jwt token}}))))
(testing "other methods return nil"
(is (nil? (sso.i/request-jwt {:request-method :put
:params {:jwt token}
:body {:jwt token}}))))))
(deftest post-jwt-body-happy-path-test
(testing "Happy path login via POST /auth/sso with JSON body {:jwt ...}, same behavior as GET with query param"
(with-jwt-default-setup!
@@ -261,6 +277,51 @@
(= {"extra" "keypairs", "are" "also present"}
(t2/select-one-fn :jwt_attributes :model/User :email "rasta@metabase.com"))))))))
(deftest post-jwt-query-param-ignored-test
(testing "POST /auth/sso does not authenticate when jwt is only in the query string"
(with-jwt-default-setup!
(let [jwt-payload (jwt/sign
{:email "rasta@metabase.com"
:first_name "Rasta"
:last_name "Toucan"
:exp (+ (buddy-util/now) 3600)
:iat (buddy-util/now)}
default-jwt-secret)
response (client/client-real-response :post 302 "/auth/sso"
{:request-options {:redirect-strategy :none}}
{}
:return_to default-redirect-uri
:jwt jwt-payload)]
(is (not (sso.test-setup/successful-login? response)))))))
(deftest post-jwt-body-precedence-test
(testing "POST /auth/sso uses jwt from JSON body, not from query params"
(with-jwt-default-setup!
(let [body-jwt (jwt/sign
{:email "rasta@metabase.com"
:first_name "Rasta"
:last_name "Toucan"
:source "body"
:exp (+ (buddy-util/now) 3600)
:iat (buddy-util/now)}
default-jwt-secret)
query-jwt (jwt/sign
{:email "crowberto@metabase.com"
:first_name "Crowberto"
:last_name "Bird"
:source "query"
:exp (+ (buddy-util/now) 3600)
:iat (buddy-util/now)}
default-jwt-secret)
response (client/client-real-response :post 302 "/auth/sso"
{:request-options {:redirect-strategy :none}}
{:jwt body-jwt}
:return_to default-redirect-uri
:jwt query-jwt)]
(is (sso.test-setup/successful-login? response))
(is (= {"source" "body"}
(t2/select-one-fn :jwt_attributes :model/User :email "rasta@metabase.com")))))))
(deftest no-open-redirect-test
(testing "Check that we prevent open redirects to untrusted sites"
(with-jwt-default-setup!

View File

@@ -186,16 +186,30 @@
"Insert new products into the transforms_products table."
[products]
(let [[schema source-table-name] (t2/select-one-fn (juxt :schema :name) :model/Table (mt/id :transforms_products))
spec (sql-jdbc.conn/db->pooled-connection-spec (mt/id))
values-list (str/join ", "
(map (fn [{:keys [name category price created-at]}]
(format "('%s', '%s', %s, '%s')" name category price created-at))
products))
insert-sql (format "INSERT INTO %s (%s) VALUES %s"
(sql.u/quote-name driver/*driver* :table schema source-table-name)
(str/join "," (map #(sql.u/quote-name driver/*driver* :field %) ["name" "category" "price" "created_at"]))
values-list)]
(driver/execute-raw-queries! driver/*driver* spec [[insert-sql]])))
spec (sql-jdbc.conn/db->pooled-connection-spec (mt/id))
table-sql (sql.u/quote-name driver/*driver* :table schema source-table-name)
field-sql #(sql.u/quote-name driver/*driver* :field %)]
(if (= driver/*driver* :clickhouse)
;; ClickHouse has no auto-increment: a plain INSERT leaves `id` at the column default (0), which
;; falls below any existing integer checkpoint. Assign explicit ids continuing from the current max.
(driver/execute-raw-queries!
driver/*driver* spec
(vec (for [{:keys [name category price created-at]} products]
[(format "INSERT INTO %s (%s) SELECT max(%s) + 1, '%s', '%s', %s, '%s' FROM %s"
table-sql
(str/join "," (map field-sql ["id" "name" "category" "price" "created_at"]))
(field-sql "id")
name category price created-at
table-sql)])))
(let [values-list (str/join ", "
(map (fn [{:keys [name category price created-at]}]
(format "('%s', '%s', %s, '%s')" name category price created-at))
products))
insert-sql (format "INSERT INTO %s (%s) VALUES %s"
table-sql
(str/join "," (map field-sql ["name" "category" "price" "created_at"]))
values-list)]
(driver/execute-raw-queries! driver/*driver* spec [[insert-sql]])))))
(defn- delete-test-products!
[products]
@@ -290,9 +304,7 @@
distinct-timestamps (get-distinct-timestamp-count target-table)]
(is (= 16 row-count) "Should still have 16 rows, no new data")
(is (= 1 distinct-timestamps) "Should still have 1 distinct timestamp"))))
(when (and (isa? driver/hierarchy driver/*driver* :sql-jdbc)
(not= driver/*driver* :clickhouse)
(not= driver/*driver* :snowflake))
(when (isa? driver/hierarchy driver/*driver* :sql-jdbc)
(testing "After inserting new data, incremental run appends only new rows"
(with-insert-test-products!
[{:name "Incremental Twice Product"
@@ -440,10 +452,7 @@
distinct-timestamps (get-distinct-timestamp-count target-table)]
(is (= 16 row-count) "Should still have 16 rows, no new data")
(is (= 1 distinct-timestamps) "Should still have 1 distinct timestamp"))))
(when (and (isa? driver/hierarchy driver/*driver* :sql-jdbc) ; insert/delete test products only works for jdbc drivers at the moment
(not= driver/*driver* :clickhouse)
;; this *should* work see #68965 for context, will plan follow-up task
(not= driver/*driver* :snowflake))
(when (isa? driver/hierarchy driver/*driver* :sql-jdbc) ; insert/delete test products only works for jdbc drivers at the moment
(testing "Add new data and run incrementally"
(with-insert-test-products!
[{:name "After Switch Product"
@@ -689,9 +698,7 @@
(transforms.execute/execute! transform {:run-method :manual})
(let [row-count (get-table-row-count target-table)]
(is (= 16 row-count) "Should still have 16 rows, no new data"))))
(when (and (isa? driver/hierarchy driver/*driver* :sql-jdbc)
(not= driver/*driver* :clickhouse)
(not= driver/*driver* :snowflake))
(when (isa? driver/hierarchy driver/*driver* :sql-jdbc)
(testing "After inserting new data, incremental run appends only new rows"
(with-insert-test-products!
[{:name "New Table Tag Product"
@@ -706,8 +713,7 @@
(is (some? checkpoint) "Checkpoint should be updated"))))))))))))))))
(defn- incremental-index-test-drivers
"Index-supporting drivers that also run incremental transforms (effectively postgres today; the incremental suite
excludes redshift/clickhouse/sqlserver)."
"Index-supporting drivers that also run incremental transforms"
[]
(into #{} (filter (index-util/index-test-drivers)) (test-drivers)))

View File

@@ -26,6 +26,17 @@
[2 "created" 2]
[3 "created" 3]]]])
;; Like merge-test but with a temporal checkpoint column (regression coverage for GDGT-2847).
(mt/defdataset merge-temporal-test
"An event-log dataset whose incremental checkpoint column is a timestamp instead of an integer."
[["order_status_ts"
[{:field-name "order_id" :base-type :type/Integer}
{:field-name "status" :base-type :type/Text}
{:field-name "changed_at" :base-type :type/DateTime}]
[[1 "created" #t "2026-01-30T10:00:00"]
[2 "created" #t "2026-01-31T21:00:04"]
[3 "created" #t "2026-01-31T21:00:04"]]]])
;; Two key columns (order_id, region) for testing a composite merge key.
(mt/defdataset merge-composite-test
"An event log keyed on (order_id, region), for testing a two-column merge key."
@@ -120,6 +131,73 @@
(target-status-by-order target-table))
"order 1 updated (paid->shipped), order 4 inserted")))))))))
(deftest merge-temporal-checkpoint-test
(testing "a merge transform with a temporal checkpoint column upserts correctly (GDGT-2847)"
(mt/test-drivers (test-drivers)
(mt/with-premium-features #{:transforms-basic}
(mt/dataset merge-temporal-test
(with-transform-cleanup! [target-table {:type :table
:name "merge_temporal"
:schema (t2/select-one-fn :schema :model/Table
(mt/id :order_status_ts))
:database (mt/id)}]
(let [q (fn [f] (sql.u/quote-name driver/*driver* :field f))
ts-field-id (t2/select-one-pk :model/Field :name "changed_at"
:table_id (mt/id :order_status_ts))
source-query {:database (mt/id)
:type :native
:native {:query (format "SELECT %s, %s FROM {{t}} AS %s"
(q "order_id") (q "status") (q "t"))
:template-tags {"t" {:id "t"
:name "t"
:display-name "T"
:type "table"
:table-id (mt/id :order_status_ts)
:required true}}}}
payload {:name "Temporal Merge"
:source_database_id (mt/id)
:source {:type "query"
:query source-query
:source-incremental-strategy {:type "checkpoint"
:checkpoint-filter-field-id ts-field-id}}
:target (merge target-table
{:type "table-incremental"
:target-incremental-strategy {:type "merge"
:unique-key [{:name "order_id"}]}})}
insert! (fn [rows]
(let [[schema tbl] (t2/select-one-fn (juxt :schema :name) :model/Table
(mt/id :order_status_ts))
spec (sql-jdbc.conn/db->pooled-connection-spec (mt/id))
values (str/join ", " (map (fn [[oid st ts]]
(format "(%d, '%s', '%s')" oid st ts))
rows))
sql (format "INSERT INTO %s (%s) VALUES %s"
(sql.u/quote-name driver/*driver* :table schema tbl)
(str/join "," (map q ["order_id" "status" "changed_at"]))
values)]
(driver/execute-raw-queries! driver/*driver* spec [[sql]])))
read-target (fn []
(let [sql (format "SELECT %s, %s FROM %s"
(q "order_id") (q "status")
(sql.u/quote-name driver/*driver* :table
(:schema target-table) (:name target-table)))
rows (-> (qp/process-query {:database (mt/id) :type :native :native {:query sql}})
:data :rows)]
(into {} (map (fn [[oid st]] [(long oid) st])) rows)))]
(mt/with-temp [:model/Transform transform payload]
(testing "first run builds the full target"
(transforms.execute/execute! transform {:run-method :manual})
(transforms.tu/wait-for-table (:name target-table) 10000)
(is (= {1 "created", 2 "created", 3 "created"}
(read-target))))
(testing "a second run with a temporal watermark upserts changed keys and inserts new ones"
(insert! [[1 "shipped" "2026-02-01T10:00:00"] [4 "created" "2026-02-02T10:00:00"]])
(transforms.execute/execute! (t2/select-one :model/Transform (:id transform))
{:run-method :manual})
(is (= {1 "shipped", 2 "created", 3 "created", 4 "created"}
(read-target))
"order 1 updated, order 4 inserted"))))))))))
(deftest merge-resolves-field-ids-test
(testing "after the first build the stored unique key gets its field-ids resolved"
(mt/test-drivers (test-drivers)

View File

@@ -1,6 +1,6 @@
{
"name": "@metabase/embedding-sdk-react",
"version": "0.63.0-alpha",
"version": "0.64.0-alpha",
"description": "Metabase Embedding SDK for React",
"bin": "./dist/cli.js",
"repository": {

View File

@@ -41,17 +41,7 @@
}
}
.ItemLink {
font-size: inherit !important;
&:hover {
color: var(--mb-color-core-brand);
}
}
.ItemLink,
.CollectionLink {
:hover {
color: var(--mb-color-core-brand);
}
.ItemLink:hover,
.CollectionLink :hover {
color: var(--mb-color-core-brand);
}

View File

@@ -3,7 +3,6 @@ import { useMemo } from "react";
import { getShortStrategyLabel } from "metabase/admin/performance/utils";
import { EllipsifiedCollectionPath } from "metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath";
import { Link } from "metabase/common/components/Link";
import { MaybeLink } from "metabase/common/components/MaybeLink/MaybeLink.styled";
import { useGetIcon } from "metabase/hooks/use-icon";
import { Box, Button, Ellipsified, FixedSizeIcon, Flex } from "metabase/ui";
import * as Urls from "metabase/urls";
@@ -48,6 +47,20 @@ export const TableRowForCacheableItem = ({
}
};
const cellContent = (
<Flex
className={StrategyEditorForQuestionsAndDashboardsS.ItemLink}
align="center"
wrap="nowrap"
gap="sm"
fw={700}
style={{ overflow: "hidden" }}
>
{iconName ? <FixedSizeIcon name={iconName} /> : <Box h="sm" w="md" />}
<Ellipsified>{name}</Ellipsified>
</Flex>
);
return (
<tr
className={
@@ -56,26 +69,7 @@ export const TableRowForCacheableItem = ({
: undefined
}
>
<td>
<MaybeLink
className={StrategyEditorForQuestionsAndDashboardsS.ItemLink}
to={url}
>
<Flex
align="center"
wrap="nowrap"
gap="sm"
style={{ overflow: "hidden" }}
>
{iconName ? (
<FixedSizeIcon name={iconName} />
) : (
<Box h="sm" w="md" />
)}
<Ellipsified style={{ fontWeight: "bold" }}>{name}</Ellipsified>
</Flex>
</MaybeLink>
</td>
<td>{url ? <Link to={url}>{cellContent}</Link> : cellContent}</td>
<td>
{collection && (
<Link

View File

@@ -24,7 +24,7 @@ export function getDataStudioTableRoutes(IsAdmin: ComponentType) {
<Route path=":tableId/fields" component={TableFieldsPage} />
<Route path=":tableId/fields/:fieldId" component={TableFieldsPage} />
<Route path=":tableId/segments" component={TableSegmentsPage} />
<Route path=":tableId/segments/new" component={IsAdmin}>
<Route path=":tableId/segments/new" element={<IsAdmin />}>
<Route index component={PublishedTableNewSegmentPage} />
</Route>
<Route
@@ -44,7 +44,7 @@ export function getDataStudioTableRoutes(IsAdmin: ComponentType) {
</Route>
)}
<Route path=":tableId/measures" component={TableMeasuresPage} />
<Route path=":tableId/measures/new" component={IsAdmin}>
<Route path=":tableId/measures/new" element={<IsAdmin />}>
<Route index component={PublishedTableNewMeasurePage} />
</Route>
<Route

View File

@@ -39,12 +39,12 @@ export function initializePlugin() {
PLUGIN_DB_ROUTING.getDestinationDatabaseRoutes = (IsAdmin: any) => (
<Route path="destination-databases">
<Route index component={DestinationDatabasesModal} />
<Route component={IsAdmin}>
<Route element={<IsAdmin />}>
<Route path="create" component={DestinationDatabaseConnectionModal} />
</Route>
<Route path=":destinationDatabaseId">
<Route index component={DestinationDatabaseConnectionModal} />
<Route component={IsAdmin}>
<Route element={<IsAdmin />}>
<Route path="remove" component={RemoveDestinationDatabaseModal} />
</Route>
</Route>

View File

@@ -0,0 +1,22 @@
.policyCardButton:active {
transform: none;
}
.policyCardButtonInner {
justify-content: space-between;
}
.policyCardLabel {
padding-right: 2rem;
}
.optionsButton {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
}
.optionsButton:active {
transform: translateY(-50%);
}

View File

@@ -21,7 +21,18 @@ import { QuestionName } from "metabase/common/components/QuestionName";
import { useToggle } from "metabase/common/hooks/use-toggle";
import { useTranslateContent } from "metabase/content-translation/hooks";
import CS from "metabase/css/core/index.css";
import { Button, Center, Icon, Loader, Radio, Stack } from "metabase/ui";
import {
ActionIcon,
Box,
Button,
Center,
Icon,
Loader,
Menu,
Radio,
Stack,
} from "metabase/ui";
import * as Urls from "metabase/urls";
import { getName } from "metabase/utils/name";
import type {
GroupTableAccessPolicyDraft,
@@ -42,6 +53,8 @@ import {
DataAttributeMappingEditor,
} from "../AttributeMappingEditor";
import S from "./EditSandboxingModal.module.css";
// eslint-disable-next-line ttag/no-module-declaration -- see metabase#55045
const ERROR_MESSAGE = t`An error occurred.`;
@@ -204,20 +217,50 @@ const EditSandboxingModal = ({
<div className={CS.pb2}>
{t`Pick a saved question that returns the custom view of this table that these users should see.`}
</div>
<Button
data-testid="custom-view-picker-button"
onClick={showModal}
fullWidth
rightSection={<Icon name="ellipsis" />}
styles={{
inner: {
justifyContent: "space-between",
},
root: { "&:active": { transform: "none" } },
}}
>
{policyCard?.name ?? t`Select a question`}
</Button>
<Box pos="relative">
<Button
data-testid="custom-view-picker-button"
onClick={showModal}
fullWidth
rightSection={policyCard ? undefined : <Icon name="ellipsis" />}
classNames={{
root: S.policyCardButton,
inner: S.policyCardButtonInner,
label: policyCard ? S.policyCardLabel : undefined,
}}
>
{policyCard?.name ?? t`Select a question`}
</Button>
{policyCard && (
<Menu position="bottom-end">
<Menu.Target>
<ActionIcon
aria-label={t`Question options`}
className={S.optionsButton}
>
<Icon name="ellipsis" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
component="a"
href={Urls.card(policyCard)}
target="_blank"
rel="noreferrer"
leftSection={<Icon name="external" />}
>
{t`Go to question`}
</Menu.Item>
<Menu.Item
onClick={showModal}
leftSection={<Icon name="refresh" />}
>
{t`Replace`}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Box>
{showPickerModal && (
<QuestionPickerModal
value={

View File

@@ -318,5 +318,55 @@ describe("EditSandboxingModal", () => {
table_id: 1,
});
});
describe("selected question menu", () => {
const policyWithCard: GroupTableAccessPolicy = {
id: 1,
table_id: 1,
group_id: 1,
card_id: TEST_CARD.id,
permission_id: 50,
attribute_remappings: {
foo: ["dimension", ["field", 13, null]],
},
};
it("should allow opening the selected question in a new tab", async () => {
await setup({ shouldMockQuestions: true, policy: policyWithCard });
await userEvent.click(screen.getByLabelText("Question options"));
const link = await screen.findByRole("menuitem", {
name: /Go to question/,
});
expect(link).toHaveAttribute("href", "/question/1-sandbox-question");
expect(link).toHaveAttribute("target", "_blank");
});
it("should allow choosing a different question from the menu", async () => {
await setup({ shouldMockQuestions: true, policy: policyWithCard });
await userEvent.click(screen.getByLabelText("Question options"));
await userEvent.click(
await screen.findByRole("menuitem", {
name: /Replace/,
}),
);
expect(
await screen.findByTestId("entity-picker-modal"),
).toBeInTheDocument();
});
it("should open the question picker when clicking the button outside the menu trigger", async () => {
await setup({ shouldMockQuestions: true, policy: policyWithCard });
await userEvent.click(screen.getByTestId("custom-view-picker-button"));
expect(
await screen.findByTestId("entity-picker-modal"),
).toBeInTheDocument();
});
});
});
});

View File

@@ -103,6 +103,7 @@ export const LoginAttributeMappingEditor = ({
const [entries, setEntries] = useState<MappingEditorEntry[]>(
structuredAttributes ? [] : buildEntries(simpleAttributes),
);
const [autoFocusIndex, setAutoFocusIndex] = useState<number | null>(null);
useEffect(() => {
// structuredAttributes can change if a different tenant is selected
@@ -121,6 +122,7 @@ export const LoginAttributeMappingEditor = ({
onError(hasError(newEntries));
} else {
onChange(buildMapping(newEntries));
setAutoFocusIndex(newEntries.length - 1);
}
};
@@ -143,6 +145,7 @@ export const LoginAttributeMappingEditor = ({
}}
keyOpts={keyOpts}
error={entryError(entries, key)}
autoFocus={index === autoFocusIndex}
/>
</Flex>
<Flex gap="sm" w="50%">
@@ -175,7 +178,9 @@ export const LoginAttributeMappingEditor = ({
variant="light"
size="xs"
leftSection={<Icon name="add" />}
onClick={() => handleChange(addEntry(entries))}
onClick={() => {
handleChange(addEntry(entries));
}}
mt="md"
disabled={_.some(
entries,
@@ -193,11 +198,13 @@ const KeyInput = ({
value,
onChange,
error,
autoFocus,
}: {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
keyOpts: MappingEditorEntry["keyOpts"];
error?: string | boolean;
autoFocus?: boolean;
}) => {
if (keyOpts?.disabled) {
return (
@@ -215,6 +222,7 @@ const KeyInput = ({
w="100%"
onChange={onChange}
error={error}
autoFocus={autoFocus}
/>
);
};

View File

@@ -162,6 +162,20 @@ describe("LoginAttributeMappingEditor", () => {
});
});
it("focuses the key input of a newly added attribute", async () => {
setup({ simpleAttributes });
const keyInputs = await screen.findAllByPlaceholderText("Key");
keyInputs.forEach((input) => expect(input).not.toHaveFocus());
await userEvent.click(
await screen.findByRole("button", { name: /Add an attribute/ }),
);
const newKeyInput = (await screen.findAllByPlaceholderText("Key")).at(-1);
expect(newKeyInput).toHaveFocus();
});
it("can remove an attribute", async () => {
const { onChange } = setup({ simpleAttributes });

View File

@@ -37,7 +37,9 @@ export function AliasInput({
}
function handleBlur() {
onChange(value);
if (value !== selection.alias) {
onChange(value);
}
}
return (

View File

@@ -0,0 +1,62 @@
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "__support__/ui";
import { createMockTable } from "metabase-types/api/mocks";
import type { TableSelection } from "../types";
import { AliasInput } from "./AliasInput";
const TABLE = createMockTable();
const SELECTION: TableSelection = {
tableId: 1,
alias: "test_table",
};
function setup() {
const onChange = jest.fn();
renderWithProviders(
<AliasInput
selection={SELECTION}
table={TABLE}
usedAliases={new Set([SELECTION.alias])}
onChange={onChange}
/>,
);
return { onChange };
}
describe("AliasInput", () => {
it("should not call onChange when blurred without changes (GDGT-1570)", async () => {
const { onChange } = setup();
await userEvent.click(screen.getByDisplayValue("test_table"));
await userEvent.tab();
expect(onChange).not.toHaveBeenCalled();
});
it("should call onChange with the new value when blurred after a change", async () => {
const { onChange } = setup();
await userEvent.type(screen.getByDisplayValue("test_table"), "x");
await userEvent.tab();
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("test_tablex");
});
it("should not call onChange when a change is reverted before blurring (GDGT-1570)", async () => {
const { onChange } = setup();
const input = screen.getByDisplayValue("test_table");
await userEvent.type(input, "x");
await userEvent.type(input, "{backspace}");
await userEvent.tab();
expect(onChange).not.toHaveBeenCalled();
});
});

View File

@@ -56,7 +56,7 @@ export function updateTransformSignature(
// Replace existing Args section
updatedScript = updatedScript.replace(
argsRegex,
` $1
`$1
Args:
${newArgsSection}

View File

@@ -0,0 +1,55 @@
import type { PythonTransformTableAliases } from "metabase-types/api";
import { createMockTable } from "metabase-types/api/mocks";
import { updateTransformSignature } from "./utils";
const TEMPLATE_BODY = `# Write your Python transformation script here
import common
import pandas as pd
def transform():
"""
Your transformation function.
Select tables above to add them as function parameters.
Returns:
DataFrame to write to the destination table
"""
# Your transformation logic here
return pd.DataFrame([{"message": "Hello from Python transform!"}])`;
const TABLE = createMockTable();
const TABLES: PythonTransformTableAliases = [
{ alias: "orders", table_id: 1, schema: "PUBLIC", database_id: 1 },
];
describe("updateTransformSignature", () => {
it("should add the table alias to the signature and the Args docstring section", () => {
const script = updateTransformSignature(TEMPLATE_BODY, TABLES, [TABLE]);
expect(script).toContain("def transform(orders):");
expect(script).toContain("Args:");
expect(script).toContain("orders: DataFrame containing the data");
});
it("should be idempotent, so re-applying the same tables does not change the script (GDGT-1570)", () => {
const script = updateTransformSignature(TEMPLATE_BODY, TABLES, [TABLE]);
const scriptAfter2ndPass = updateTransformSignature(script, TABLES, [
TABLE,
]);
expect(scriptAfter2ndPass).toBe(script);
});
it("should not leave trailing whitespace on the signature line when replacing an existing Args section (GDGT-1570)", () => {
const script = updateTransformSignature(TEMPLATE_BODY, TABLES, [TABLE]);
const scriptAfter2ndPass = updateTransformSignature(script, TABLES, [
TABLE,
]);
expect(scriptAfter2ndPass).toContain("def transform(orders):\n");
expect(scriptAfter2ndPass).not.toMatch(/[ ]+\n/);
});
});

View File

@@ -13,7 +13,7 @@ export function getDataStudioRoutes() {
export function getWorkspaceDatabaseRoutes(IsAdmin: RouteComponent) {
return (
<Route component={IsAdmin}>
<Route element={<IsAdmin />}>
<Route path=":databaseId/admin" component={AdminConnectionInfoPage} />
</Route>
);

View File

@@ -161,8 +161,6 @@ export type SearchRequest = {
model_ancestors?: boolean | null;
include_dashboard_questions?: boolean | null;
include_metadata?: boolean | null;
non_temporal_dim_ids?: string | null;
has_temporal_dim?: boolean | null;
search_engine?: "appdb" | "in-place" | "semantic" | null;
display_type?: string[] | null;

View File

@@ -15,7 +15,7 @@ export const getAccountRoutes = (
IsAuthenticated: RouteComponent,
) => {
return (
<Route path="/account" component={IsAuthenticated}>
<Route path="/account" element={<IsAuthenticated />}>
<Route component={AccountApp}>
<Route index component={redirect("profile")} />
<Route path="profile" component={UserProfileApp} />

View File

@@ -90,12 +90,12 @@ export const getRoutes = (
const hasSimpleEmbedding = getTokenFeature(state, "embedding_simple");
return (
<Route path="/admin" component={CanAccessSettings}>
<Route path="/admin" element={<CanAccessSettings />}>
<Route component={AdminApp}>
<Route index component={RedirectToAllowedSettings} />
<Route path="databases" component={createAdminRouteGuard("databases")}>
<Route index component={DatabaseListApp} />
<Route component={IsAdmin}>
<Route element={<IsAdmin />}>
<Route path="create" component={DatabasePage} />
</Route>
<Route path=":databaseId/edit" component={DatabasePage} />
@@ -124,10 +124,10 @@ export const getRoutes = (
/>
<Route component={DataModelV1}>
<Route path="segments" component={SegmentListApp} />
<Route path="segment/create" component={IsAdmin}>
<Route path="segment/create" element={<IsAdmin />}>
<Route index component={SegmentApp} />
</Route>
<Route path="segment/:id" component={IsAdmin}>
<Route path="segment/:id" element={<IsAdmin />}>
<Route index component={SegmentApp} />
</Route>
<Route
@@ -256,7 +256,7 @@ export const getRoutes = (
{getSettingsRoutes(store, IsAdmin)}
</Route>
{/* PERMISSIONS */}
<Route path="permissions" component={IsAdmin}>
<Route path="permissions" element={<IsAdmin />}>
{getAdminPermissionsRoutes()}
</Route>

View File

@@ -1,6 +1,5 @@
import { render as testingLibraryRender } from "@testing-library/react";
import fetchMock from "fetch-mock";
import type { ReactNode } from "react";
import {
setupEnterpriseOnlyPlugin,
@@ -26,7 +25,7 @@ import { mockSettings } from "__support__/settings";
import { getTestStoreAndWrapper, screen } from "__support__/ui";
import { getSettingsRoutes } from "metabase/admin/settingsRoutes";
import { createMockState } from "metabase/redux/store/mocks";
import { Route } from "metabase/router";
import { Outlet, Route } from "metabase/router";
import type { TokenFeature, TokenFeatures } from "metabase-types/api";
import {
createMockSettings,
@@ -206,9 +205,7 @@ export const setup = async ({
initialRoute: `/admin/settings${initialRoute}`,
});
const PassThroughGuard = ({ children }: { children: ReactNode }) => (
<>{children}</>
);
const PassThroughGuard = () => <Outlet />;
testingLibraryRender(
<Route path="admin/settings">

View File

@@ -88,7 +88,7 @@ export const getSettingsRoutes = (
<Route
path="custom-visualizations"
/* do not allow users with "Settings access" permissions to access custom viz pages */
component={IsAdmin}
element={<IsAdmin />}
>
<Route index component={CustomVisualizationsManagePage} />
<Route path="new" component={CustomVisualizationsFormPage} />

View File

@@ -14,7 +14,6 @@ import { SortableColumnHeader } from "metabase/common/components/ItemsTable/Base
import {
ColumnHeader,
ItemNameCell,
MaybeItemLink,
TBody,
Table,
TableColumn,
@@ -229,36 +228,28 @@ function preventDefault(event: MouseEvent) {
function NameCell({ metric }: { metric?: MetricResult }) {
const headingId = `metric-${metric?.id ?? "dummy"}-heading`;
return (
<ItemNameCell
data-testid="metric-name"
aria-labelledby={headingId}
{...nameProps}
>
<MaybeItemLink
to={
metric
? Urls.metric({ id: metric.id, name: metric.name, type: "metric" })
: undefined
}
style={{
// To align the icons with "Name" in the <th>
paddingInlineStart: "1.4rem",
paddingInlineEnd: ".5rem",
}}
onClick={preventDefault}
>
<Flex align="center" gap="0.5rem" ps="1.4rem" pe="0.5rem">
{metric ? (
<EntityItem.Name
name={metric?.name || ""}
variant="list"
id={headingId}
/>
<Link
to={Urls.metric({
id: metric.id,
name: metric.name,
type: "metric",
})}
onClick={preventDefault}
>
<EntityItem.Name name={metric.name} variant="list" id={headingId} />
</Link>
) : (
<SkeletonText />
)}
</MaybeItemLink>
</Flex>
</ItemNameCell>
);
}

View File

@@ -9,7 +9,6 @@ import { EntityItem } from "metabase/common/components/EntityItem";
import { SortableColumnHeader } from "metabase/common/components/ItemsTable/BaseItemsTable";
import {
ItemNameCell,
MaybeItemLink,
TBody,
Table,
TableColumn,
@@ -210,35 +209,28 @@ function NameCell({ model }: { model?: ModelResult }) {
const getIcon = useGetIcon();
const headingId = `model-${model?.id || "dummy"}-heading`;
const icon = getIcon(model ?? { model: "dataset" }) ?? { name: "folder" };
const name = <EntityItem.Name name={model?.name || ""} variant="list" />;
return (
<ItemNameCell data-testid="model-name" aria-labelledby={headingId}>
<MaybeItemLink
to={
model
? Urls.model({ id: model.id, name: model.name, type: "model" })
: undefined
}
style={{
// To align the icons with "Name" in the <th>
paddingInlineStart: "1.4rem",
paddingInlineEnd: ".5rem",
}}
onClick={preventDefault}
>
<Flex id={headingId} align="center" gap="0.5rem" ps="1.4rem" pe="0.5rem">
<EntityIcon
size="1rem"
{...icon}
color="icon-brand"
style={{ flexShrink: 0 }}
/>
{
<EntityItem.Name
name={model?.name || ""}
variant="list"
id={headingId}
/>
}
</MaybeItemLink>
{model ? (
<Link
to={Urls.model({ id: model.id, name: model.name, type: "model" })}
onClick={preventDefault}
style={{ overflow: "hidden" }}
>
{name}
</Link>
) : (
name
)}
</Flex>
</ItemNameCell>
);
}

View File

@@ -1,25 +1,9 @@
// eslint-disable-next-line no-restricted-imports
import { css } from "@emotion/react";
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { MarkdownPreview } from "metabase/common/components/MarkdownPreview";
import { RawMaybeLink } from "metabase/common/components/MaybeLink/MaybeLink.styled";
import { Box, type BoxProps } from "metabase/ui";
export const ItemLink = styled(RawMaybeLink)<{ to?: string }>`
display: block;
height: min-content;
${(props) =>
props.to
? ""
: css`
${Body} {
cursor: default;
}
`}
`;
// Unjustified type cast. FIXME
export const ActionsContainer = styled(Box)<BoxProps>`
display: flex;

View File

@@ -9,9 +9,10 @@ import type {
} from "metabase/common/collections/types";
import { EntityIcon } from "metabase/common/components/EntityIcon";
import { EventSandbox } from "metabase/common/components/EventSandbox";
import { Link } from "metabase/common/components/Link";
import { useGetIcon } from "metabase/hooks/use-icon";
import { PLUGIN_MODERATION } from "metabase/plugins";
import { Card, Flex, Skeleton, Tooltip } from "metabase/ui";
import { Box, Card, Flex, Skeleton, Tooltip } from "metabase/ui";
import { modelToUrl } from "metabase/urls";
import type Database from "metabase-lib/v1/metadata/Database";
import type {
@@ -27,7 +28,6 @@ import {
Body,
Description,
Header,
ItemLink,
Title,
} from "./PinnedItemCard.styled";
@@ -119,71 +119,74 @@ function PinnedItemCard({
isCollectionItem(item) &&
(onCopy || onMove || createBookmark || deleteBookmark || collection);
const card = (
<Card p={0} radius="md" shadow="none" withBorder>
<Body style={item ? undefined : { cursor: "default" }}>
<Header>
<EntityIcon {...iconData} size="1.5rem" color="core-brand" />
<ActionsContainer h={item ? undefined : "2rem"}>
{hasActions && (
// This component is used within a `<Link>` component,
// so we must prevent events from triggering the activation of the link
<EventSandbox preventDefault sandboxedEvents={["onClick"]}>
<ActionMenu
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
item={item}
collection={collection}
onCopy={onCopy}
onMove={onMove}
/>
</EventSandbox>
)}
</ActionsContainer>
</Header>
{item ? (
<>
<Tooltip
label={item.name}
position="bottom"
disabled={!showTitleTooltip}
maw={TOOLTIP_MAX_WIDTH}
>
<Title
onMouseEnter={(e) => maybeEnableTooltip(e, setShowTitleTooltip)}
>
<Flex align="center" gap="0.5rem">
{item.name}
<PLUGIN_MODERATION.ModerationStatusIcon
status={item.moderated_status}
filled
size={14}
/>
</Flex>
</Title>
</Tooltip>
<Description tooltipMaxWidth={TOOLTIP_MAX_WIDTH}>
{item.description || DEFAULT_DESCRIPTION[item.model] || ""}
</Description>
</>
) : (
<>
<Skeleton natural h="1.5rem" />
<Skeleton natural mt="xs" mb="4px" h="1rem" />
</>
)}
</Body>
</Card>
);
const to = item ? (modelToUrl(item) ?? "/") : undefined;
return (
<ItemLink
<Box
display="block"
h="min-content"
className={className}
to={item ? (modelToUrl(item) ?? "/") : undefined}
onClick={onClick}
>
<Card p={0} radius="md" shadow="none" withBorder>
<Body>
<Header>
<EntityIcon {...iconData} size="1.5rem" color="core-brand" />
<ActionsContainer h={item ? undefined : "2rem"}>
{hasActions && (
// This component is used within a `<Link>` component,
// so we must prevent events from triggering the activation of the link
<EventSandbox preventDefault sandboxedEvents={["onClick"]}>
<ActionMenu
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
item={item}
collection={collection}
onCopy={onCopy}
onMove={onMove}
/>
</EventSandbox>
)}
</ActionsContainer>
</Header>
{item ? (
<>
<Tooltip
label={item.name}
position="bottom"
disabled={!showTitleTooltip}
maw={TOOLTIP_MAX_WIDTH}
>
<Title
onMouseEnter={(e) =>
maybeEnableTooltip(e, setShowTitleTooltip)
}
>
<Flex align="center" gap="0.5rem">
{item.name}
<PLUGIN_MODERATION.ModerationStatusIcon
status={item.moderated_status}
filled
size={14}
/>
</Flex>
</Title>
</Tooltip>
<Description tooltipMaxWidth={TOOLTIP_MAX_WIDTH}>
{item.description || DEFAULT_DESCRIPTION[item.model] || ""}
</Description>
</>
) : (
<>
<Skeleton natural h="1.5rem" />
<Skeleton natural mt="xs" mb="4px" h="1rem" />
</>
)}
</Body>
</Card>
</ItemLink>
{to ? <Link to={to}>{card}</Link> : card}
</Box>
);
}

View File

@@ -239,6 +239,13 @@ export function ExplicitSize<T>({
const box = entry.borderBoxSize?.[0];
width = box ? box.inlineSize : entry.contentRect.width;
height = box ? box.blockSize : entry.contentRect.height;
} else if (
this._printMediaQuery?.matches &&
element instanceof HTMLElement
) {
/* Print layout dimensions must exclude browser zoom transforms. */
width = element.offsetWidth;
height = element.offsetHeight;
} else {
const rect = element.getBoundingClientRect();
width = rect.width;

View File

@@ -1,6 +1,7 @@
import { act, render, screen } from "@testing-library/react";
import { forwardRef } from "react";
import { createMockMediaQueryList } from "__support__/ui";
import resizeObserver from "metabase/utils/resize-observer";
import { ExplicitSize } from "./ExplicitSize";
@@ -114,6 +115,7 @@ describe("ExplicitSize", () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it("should pass the size from resize observer entries to the wrapped component", () => {
@@ -157,6 +159,26 @@ describe("ExplicitSize", () => {
expect(screen.getByTestId("sized")).toHaveTextContent("400x868");
});
it("should use untransformed layout dimensions while printing (#74181)", () => {
jest
.spyOn(window, "matchMedia")
.mockReturnValue(createMockMediaQueryList({ matches: true }));
render(<SizedComponent />);
const element = screen.getByTestId("sized");
Object.defineProperties(element, {
offsetWidth: { value: 400 },
offsetHeight: { value: 300 },
});
jest
.spyOn(element, "getBoundingClientRect")
.mockReturnValue(new DOMRect(0, 0, 200, 150));
triggerResize(element, { target: document.body });
expect(screen.getByTestId("sized")).toHaveTextContent("400x300");
});
it("should measure a child that renders content only after mount and pass it non-null dimensions (metabase#51926)", () => {
// Regression test for the pivot table rendering blank until resized.
// PivotTable returns null on its first render, so there is no element for

View File

@@ -12,7 +12,6 @@ import {
import { EntityItem } from "metabase/common/components/EntityItem";
import { IconButtonWrapper } from "metabase/common/components/IconButtonWrapper";
import { Link } from "metabase/common/components/Link";
import { RawMaybeLink } from "metabase/common/components/MaybeLink/MaybeLink.styled";
import AdminS from "metabase/css/admin.module.css";
import type { IconProps, TextProps } from "metabase/ui";
import { FixedSizeIcon, Text } from "metabase/ui";
@@ -112,17 +111,15 @@ export const ItemButton = styled(Text)<
export const ItemLink = styled(Link)(itemLinkStyle);
export const MaybeItemLink = styled(RawMaybeLink)(itemLinkStyle);
export const ItemNameCell = styled.td`
padding: 0 !important;
${ItemLink}, ${MaybeItemLink}, ${ItemButton} {
& > * {
padding: 1em;
}
&:hover {
${ItemLink}, ${MaybeItemLink}, ${ItemButton} {
& > * {
color: var(--mb-color-core-brand);
}

View File

@@ -1,48 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { css } from "@emotion/react";
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import type { HTMLAttributes } from "react";
import type { LinkProps } from "metabase/common/components/Link";
import { Link } from "metabase/common/components/Link";
import { maybeColor } from "metabase/ui/utils/colors";
interface RawMaybeLinkProps {
to?: string;
activeColor?: string;
inactiveColor?: string;
isSingleLine?: boolean;
}
export function RawMaybeLink({
to,
activeColor,
inactiveColor,
isSingleLine,
...props
}: RawMaybeLinkProps & (LinkProps | HTMLAttributes<HTMLSpanElement>)) {
return to ? <Link to={to} {...props} /> : <span {...props} />;
}
const hoverStyle = (props: RawMaybeLinkProps) => css`
cursor: pointer;
${props.activeColor ? `color: ${maybeColor(props.activeColor)};` : ""}
`;
/**
* @deprecated styled components are deprecated
*/
export const MaybeLink = styled(RawMaybeLink)`
display: flex;
align-items: center;
font-size: 0.875em;
font-weight: bold;
${(props) =>
props.inactiveColor ? `color: ${maybeColor(props.inactiveColor)};` : ""}
min-width: ${(props) => (props.isSingleLine ? 0 : "")};
:hover {
${(props) => (props.to || props.onClick) && hoverStyle(props)}
}
`;

View File

@@ -1,14 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
export const ContextText = styled("p")`
line-height: 1.4em;
color: var(--mb-color-text-secondary);
margin-top: 0;
`;
export const ContextContainer = styled.div`
margin-left: 42px;
margin-top: 12px;
max-width: 620px;
`;

View File

@@ -1,8 +1,6 @@
// I think it's very likely that this is a dead codepath: RL 2023-06-21
import { color } from "metabase/ui/utils/colors";
import { ContextContainer, ContextText } from "./Context.styled";
import { Box, Text, rem } from "metabase/ui";
export function Context({ context }: { context: any[] }) {
if (!context) {
@@ -10,21 +8,21 @@ export function Context({ context }: { context: any[] }) {
}
return (
<ContextContainer>
<ContextText>
<Box ml={rem(42)} mt={rem(12)} maw={rem(620)}>
<Text component="p" c="text-secondary" lh="lg">
{context.map(({ is_match, text }, i: number) => {
if (!is_match) {
return <span key={i}> {text}</span>;
}
return (
<strong key={i} style={{ color: color("core-brand") }}>
<Box component="strong" key={i} c="core-brand">
{" "}
{text}
</strong>
</Box>
);
})}
</ContextText>
</ContextContainer>
</Text>
</Box>
);
}

View File

@@ -1,38 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { lighten } from "metabase/ui/colors";
import { color } from "metabase/ui/utils/colors";
import type { SearchModel } from "metabase-types/api";
function getColorForIconWrapper(
active: boolean,
archived: boolean,
type: SearchModel,
) {
if (!active || archived) {
return color("text-secondary");
}
if (type === "collection") {
return lighten("core-brand", 0.35);
}
return color("core-brand");
}
export const IconWrapper = styled.div<{
active: boolean;
archived: boolean;
type: SearchModel;
}>`
border: 1px solid var(--mb-color-border-neutral);
border-radius: ${({ theme }) => theme.radius.sm};
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: ${({ active, archived, type }) =>
getColorForIconWrapper(active, archived, type)};
flex-shrink: 0;
background: var(--mb-color-background_page-primary);
`;

View File

@@ -1,9 +1,24 @@
import { Icon } from "metabase/ui";
import { Flex, Icon, rem } from "metabase/ui";
import { lighten } from "metabase/ui/colors";
import { color } from "metabase/ui/utils/colors";
import type { RecentItem, SearchModel, SearchResult } from "metabase-types/api";
import { CollectionIcon } from "./CollectionIcon";
import { DefaultIcon } from "./DefaultIcon";
import { IconWrapper } from "./ItemIcon.styled";
function getColorForIconWrapper(
active: boolean,
archived: boolean,
type: SearchModel,
) {
if (!active || archived) {
return color("text-secondary");
}
if (type === "collection") {
return lighten("core-brand", 0.35);
}
return color("core-brand");
}
export interface IconComponentProps {
item: SearchResult | RecentItem;
@@ -38,13 +53,19 @@ export const ItemIcon = ({
const archived = Boolean("archived" in item && item.archived);
return (
<IconWrapper
type={type}
active={active}
archived={archived}
<Flex
align="center"
justify="center"
w={rem(32)}
h={rem(32)}
flex="0 0 auto"
bd="1px solid var(--mb-color-border-neutral)"
bdrs="sm"
bg="background_page-primary"
style={{ color: getColorForIconWrapper(active, archived, type) }}
data-testid={dataTestId}
>
<IconComponent item={item} type={type} />
</IconWrapper>
</Flex>
);
};

View File

@@ -173,10 +173,7 @@ describe("SearchResult", () => {
it("renders x-ray button for indexed entity search result", () => {
setup({ result: TEST_RESULT_INDEXED_ENTITY });
expect(screen.getByTestId("search-result-item-icon")).toHaveAttribute(
"type",
"indexed-entity",
);
expect(screen.getByTestId("search-result-item-icon")).toBeInTheDocument();
expect(getIcon("bolt")).toBeInTheDocument();
});

View File

@@ -0,0 +1,16 @@
.resultLink {
color: var(--mb-color-text-secondary);
line-height: unset;
transition: color 0.2s ease-in-out;
}
.resultLink[href]:hover,
.resultLink[href]:focus,
.resultLink[href]:focus-within {
color: var(--mb-color-core-brand);
outline: 0;
}
.resultLinkWrapper {
overflow: hidden;
}

View File

@@ -1,31 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { css } from "@emotion/react";
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import type { AnchorProps, TextProps } from "metabase/ui";
import { Group } from "metabase/ui";
type ResultLinkProps = AnchorProps | TextProps;
export const ResultLink = styled.a<ResultLinkProps>`
line-height: unset;
${({ href }) => {
return (
href &&
css`
&:hover,
&:focus,
&:focus-within {
color: var(--mb-color-core-brand);
outline: 0;
}
`
);
}};
transition: color 0.2s ease-in-out;
`;
export const ResultLinkWrapper = styled(Group)`
overflow: hidden;
`;

View File

@@ -1,9 +1,9 @@
import type { MouseEvent } from "react";
import { Anchor, Text, Tooltip } from "metabase/ui";
import { Anchor, Group, Text, Tooltip } from "metabase/ui";
import { useIsTruncated } from "metabase/ui/hooks/use-is-truncated";
import { ResultLink, ResultLinkWrapper } from "./SearchResultLink.styled";
import S from "./SearchResultLink.module.css";
export const SearchResultLink = ({
children,
@@ -17,37 +17,40 @@ export const SearchResultLink = ({
const { isTruncated, ref: truncatedRef } =
useIsTruncated<HTMLAnchorElement>();
const componentProps = href
? {
as: Anchor,
href,
td: "underline",
onClick: (e: MouseEvent<HTMLAnchorElement>) => e.stopPropagation(),
}
: {
as: Text,
td: "none",
};
return (
<Tooltip disabled={!isTruncated || !children} label={children}>
<ResultLinkWrapper
<Group
className={S.resultLinkWrapper}
data-testid="result-link-wrapper"
gap="xs"
wrap="nowrap"
>
{leftIcon}
<ResultLink
{...componentProps}
span
c="text-secondary"
size="sm"
truncate
ref={truncatedRef}
>
{children}
</ResultLink>
</ResultLinkWrapper>
{href ? (
<Anchor
href={href}
td="underline"
onClick={(e: MouseEvent<HTMLAnchorElement>) => e.stopPropagation()}
className={S.resultLink}
size="sm"
truncate
ref={truncatedRef}
>
{children}
</Anchor>
) : (
<Text
td="none"
component="span"
className={S.resultLink}
size="sm"
truncate
ref={truncatedRef}
>
{children}
</Text>
)}
</Group>
</Tooltip>
);
};

View File

@@ -70,6 +70,12 @@ export const useListKeyboardNavigation = <
reset,
cursorIndex,
selectedItem,
getRef: (item: T) => (item === selectedItem ? selectedRef : undefined),
// a ref callback (not the ref object) so it can attach to elements narrower than R
getRef: (item: T) =>
item === selectedItem
? (element: R | null) => {
selectedRef.current = element;
}
: undefined,
};
};

View File

@@ -7,6 +7,13 @@
}
.DashCardActionsPanelContainer {
position: absolute;
top: 0;
/**
* Defined here in CSS rather than via style props, so that the onLeftEdge
* container query below can have higher specificity
*/
right: 20px;
min-width: fit-content;
padding: 0.125em 0.25em;
background: var(--mb-color-bg-dashboard-card);

View File

@@ -335,9 +335,6 @@ function DashCardActionsPanelInner({
},
className,
)}
pos="absolute"
top={0}
right="20px"
data-testid="dashboardcard-actions-panel"
data-dontdrag // allows to interact with the actions panel while in the edit mode
onMouseDown={onMouseDown}

View File

@@ -29,7 +29,7 @@ export function getDataStudioMetadataRoutes(IsAdmin: ComponentType) {
/>
<Route
path="database/:databaseId/schema/:schemaId/table/:tableId/segments/new"
component={IsAdmin}
element={<IsAdmin />}
>
<Route index component={DataModelNewSegmentPage} />
</Route>
@@ -51,7 +51,7 @@ export function getDataStudioMetadataRoutes(IsAdmin: ComponentType) {
)}
<Route
path="database/:databaseId/schema/:schemaId/table/:tableId/measures/new"
component={IsAdmin}
element={<IsAdmin />}
>
<Route index component={DataModelNewMeasurePage} />
</Route>

View File

@@ -35,10 +35,10 @@ export function getDataStudioRoutes(
IsAdmin: RouteComponent,
) {
return (
<Route component={CanAccessDataStudio}>
<Route element={<CanAccessDataStudio />}>
<Route path="data-studio" component={DataStudioLayout}>
<Route index component={DataStudioIndexRedirect} />
<Route path="data" component={CanAccessDataModel}>
<Route path="data" element={<CanAccessDataModel />}>
<Route component={DataSectionLayout}>
{getDataStudioMetadataRoutes(IsAdmin)}
</Route>

View File

@@ -1,3 +1,16 @@
.documentHeader {
align-items: flex-start;
background-color: var(--mb-color-background-primary);
gap: var(--mantine-spacing-md);
justify-content: space-between;
margin-bottom: var(--mantine-spacing-md);
margin-inline: auto;
margin-top: var(--mantine-spacing-xl);
max-width: 56.25rem;
padding-top: var(--mantine-spacing-xl);
width: 100%;
}
.titleInput {
border: none !important;
padding: 0 !important;
@@ -18,3 +31,10 @@
background-color: var(--mb-color-background_surface-hover);
}
}
@media print {
.documentHeader {
max-width: none;
margin-inline: 0;
}
}

View File

@@ -118,17 +118,7 @@ export const DocumentHeader = ({
);
return (
<Flex
justify="space-between"
align="flex-start"
gap="1rem"
mt="xl"
pt="xl"
pb="1rem"
maw={900}
mx="auto"
w="100%"
>
<Flex className={S.documentHeader}>
<Flex direction="column" className={S.titleContainer}>
<TextInput
aria-label={t`Document Title`}

View File

@@ -47,16 +47,18 @@
}
}
.mainContent {
padding: 0;
}
.documentContainer {
position: absolute;
visibility: visible;
top: 0;
right: 0;
z-index: 1000;
.header {
padding: 0;
}
padding: 0;
margin-inline: 0;
:global(.node-metabot),
:global(.node-cardEmbed),

View File

@@ -25,6 +25,13 @@
line-height: 1.5;
}
@media print {
.editorContent :global(.ProseMirror) {
margin-inline: 0;
max-width: none;
}
}
/* Apply typography styles only to elements NOT inside .editor-style-boundary */
.editorContent
:global(.ProseMirror)

View File

@@ -13,12 +13,12 @@ import {
import { ForwardRefLink } from "metabase/common/components/Link";
import { useToast } from "metabase/common/hooks";
import { deserializeCardFromQuery } from "metabase/common/utils/card";
import { getMetabotId } from "metabase/metabot/state";
import {
getCollectionLocationLabel,
getCollectionLocationParts,
getDatabaseLocationParts,
} from "metabase/common/utils/source-location";
import { getMetabotId } from "metabase/metabot/state";
} from "metabase/metabot/utils/source-location";
import { useSelector } from "metabase/redux";
import {
ActionIcon,

View File

@@ -0,0 +1,109 @@
.container {
border: 1px solid var(--mb-color-border-neutral);
background-color: var(--mb-color-background_page-primary);
overflow: hidden;
transition:
background 150ms,
width 0.2s;
&:not(.active):hover {
background-color: var(--mb-color-background_page-secondary);
}
&.active {
background-color: var(--mb-color-background_page-tertiary);
}
}
.input {
background-color: transparent;
border: none;
color: var(--mb-color-text-primary);
font-weight: 700;
font-size: var(--mantine-font-size-md);
flex-basis: 0;
flex-grow: 1;
&:focus {
outline: none;
}
&::placeholder {
color: var(--mb-color-text-primary);
}
}
.searchIcon {
flex-basis: 1rem;
}
.closeButton {
align-items: center;
justify-content: center;
color: var(--mb-color-text-disabled);
&:hover {
color: var(--mb-color-text-secondary);
}
}
@media (prefers-reduced-motion) {
.container {
transition: none;
}
}
@media screen and (--breakpoint-max-sm) {
.container {
justify-content: center;
margin-left: auto;
width: 2rem;
height: 2rem;
border-radius: var(--mantine-radius-xl);
border-color: transparent;
&.active {
width: 100%;
border-color: var(--mb-color-border-neutral);
border-radius: var(--mantine-radius-sm);
justify-content: flex-start;
}
}
.input {
width: 0;
flex-grow: 0;
padding: 0;
&.active {
flex-grow: 1;
padding-top: var(--mantine-spacing-sm);
padding-bottom: var(--mantine-spacing-sm);
}
}
.searchIcon {
transition: margin 0.3s;
&.active {
margin-left: var(--mantine-spacing-sm);
margin-right: var(--mantine-spacing-sm);
}
}
}
@media screen and (--breakpoint-min-sm) {
.container {
max-width: 50em;
border-radius: var(--mantine-radius-sm);
justify-content: flex-start;
}
.input {
padding: var(--mantine-spacing-sm) 0.75rem;
}
.searchIcon {
margin-left: var(--mantine-spacing-sm);
}
}

View File

@@ -1,167 +0,0 @@
import isPropValid from "@emotion/is-prop-valid";
// eslint-disable-next-line no-restricted-imports
import { css } from "@emotion/react";
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { APP_BAR_HEIGHT } from "metabase/nav/constants";
import {
breakpointMaxSmall,
breakpointMinSmall,
} from "metabase/styled-components/theme";
import { Icon } from "metabase/ui";
const activeInputCSS = css`
border-radius: 6px;
justify-content: flex-start;
`;
export const SearchBarRoot = styled.div`
width: 100%;
${breakpointMinSmall} {
max-width: 14.5rem;
position: relative;
}
`;
export const SearchInputContainer = styled.div<{
isActive: boolean;
}>`
display: flex;
flex: 1 1 auto;
align-items: center;
position: relative;
${({ isActive }) => {
if (isActive) {
return css`
background-color: var(--mb-color-background_page-tertiary);
`;
}
return css`
background-color: var(--mb-color-background_page-primary);
&:hover {
background-color: var(--mb-color-background_page-secondary);
}
`;
}}
border: 1px solid var(--mb-color-border-neutral);
overflow: hidden;
transition:
background 150ms,
width 0.2s;
@media (prefers-reduced-motion) {
transition: none;
}
${breakpointMaxSmall} {
justify-content: center;
margin-left: auto;
width: 2rem;
height: 2rem;
border-radius: 99px;
border-color: transparent;
${(props) =>
props.isActive &&
css`
width: 100%;
border-color: var(--mb-color-border-neutral);
${activeInputCSS};
`};
}
${breakpointMinSmall} {
max-width: 50em;
${activeInputCSS};
}
`;
export const SearchInput = styled.input<{
isActive: boolean;
}>`
background-color: transparent;
border: none;
color: var(--mb-color-text-primary);
font-weight: 700;
font-size: 0.875rem;
flex-basis: 0;
flex-grow: 1;
&:focus {
outline: none;
}
&::placeholder {
color: var(--mb-color-text-primary);
}
${breakpointMinSmall} {
padding: 9px 12px;
}
${breakpointMaxSmall} {
width: 0;
flex-grow: 0;
padding: 0;
${(props) =>
props.isActive &&
css`
flex-grow: 1;
padding-top: 10px;
padding-bottom: 10px;
`}
}
`;
const ICON_MARGIN = "10px";
export const SearchIcon = styled(Icon, { shouldForwardProp: isPropValid })<{
isActive: boolean;
}>`
flex-basis: 1rem;
${breakpointMaxSmall} {
transition: margin 0.3s;
${(props) =>
props.isActive &&
css`
margin-left: ${ICON_MARGIN};
margin-right: ${ICON_MARGIN};
`}
}
${breakpointMinSmall} {
margin-left: ${ICON_MARGIN};
}
`;
export const CloseSearchButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 100%;
color: var(--mb-color-text-disabled);
cursor: pointer;
&:hover {
color: var(--mb-color-text-secondary);
}
`;
export const SearchResultsFloatingContainer = styled.div`
position: absolute;
left: 0;
right: 0;
color: var(--mb-color-text-primary);
${breakpointMaxSmall} {
top: ${APP_BAR_HEIGHT};
}
${breakpointMinSmall} {
top: 42px;
}
`;

View File

@@ -1,3 +1,4 @@
import cx from "classnames";
import type { LocationDescriptorObject } from "history";
import { useKBar } from "kbar";
import type {
@@ -20,24 +21,18 @@ import {
import type { SearchAwareLocation } from "metabase/common/search/types";
import { RecentsList } from "metabase/nav/components/search/RecentsList";
import { SearchResultsDropdown } from "metabase/nav/components/search/SearchResultsDropdown";
import { APP_BAR_HEIGHT } from "metabase/nav/constants";
import { useDispatch, useSelector } from "metabase/redux";
import { push, withRouter } from "metabase/router";
import { getSetting } from "metabase/selectors/settings";
import { Icon } from "metabase/ui";
import { Box, Flex, Icon, UnstyledButton, rem } from "metabase/ui";
import { modelToUrl } from "metabase/urls";
import { isSmallScreen } from "metabase/utils/dom";
import { isWithinIframe } from "metabase/utils/iframe";
import type { SearchResult } from "metabase-types/api";
import { CommandPaletteTrigger } from "./CommandPaletteTrigger";
import {
CloseSearchButton,
SearchBarRoot,
SearchIcon,
SearchInput,
SearchInputContainer,
SearchResultsFloatingContainer,
} from "./SearchBar.styled";
import S from "./SearchBar.module.css";
const ALLOWED_SEARCH_FOCUS_ELEMENTS = new Set(["BODY", "A"]);
@@ -201,11 +196,25 @@ function SearchBarView({
};
return (
<SearchBarRoot ref={container}>
<SearchInputContainer isActive={isActive} onClick={onInputContainerClick}>
<SearchIcon name="search" isActive={isActive} />
<SearchInput
isActive={isActive}
<Box
ref={container}
w="100%"
maw={{ sm: rem(232) }}
pos={{ sm: "relative" }}
>
<Flex
className={cx(S.container, { [S.active]: isActive })}
align="center"
pos="relative"
flex="1 1 auto"
onClick={onInputContainerClick}
>
<Icon
name="search"
className={cx(S.searchIcon, { [S.active]: isActive })}
/>
<input
className={cx(S.input, { [S.active]: isActive })}
value={searchText}
placeholder={t`Search` + "…"}
maxLength={200}
@@ -214,16 +223,30 @@ function SearchBarView({
ref={searchInput}
/>
{isSmallScreen() && isActive && (
<CloseSearchButton onClick={handleClickOnClose}>
<UnstyledButton
className={S.closeButton}
display="flex"
w={rem(48)}
h="100%"
aria-label={t`Close search`}
onClick={handleClickOnClose}
>
<Icon name="close" />
</CloseSearchButton>
</UnstyledButton>
)}
{!isSmallScreen() && !isWithinIframe() && isActive && (
<CommandPaletteTrigger onClick={handleCommandPaletteTriggerClick} />
)}
</SearchInputContainer>
</Flex>
{isActive && isTypeaheadEnabled && (
<SearchResultsFloatingContainer data-testid="search-results-floating-container">
<Box
pos="absolute"
left={0}
right={0}
top={{ base: APP_BAR_HEIGHT, sm: rem(42) }}
c="text-primary"
data-testid="search-results-floating-container"
>
{hasSearchText ? (
<SearchResultsDropdown
searchText={searchText}
@@ -234,9 +257,9 @@ function SearchBarView({
) : (
<RecentsList />
)}
</SearchResultsFloatingContainer>
</Box>
)}
</SearchBarRoot>
</Box>
);
}

View File

@@ -0,0 +1,7 @@
.searchResultsList {
overflow: hidden;
}
.resultsContainer {
overflow-y: auto;
}

View File

@@ -1,22 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { Stack } from "metabase/ui";
export const EmptyStateContainer = styled.div`
margin-top: 4rem;
margin-bottom: 2rem;
`;
export const SearchResultsList = styled(Stack)`
overflow: hidden;
`;
export const ResultsContainer = styled.ul`
overflow-y: auto;
padding: 0.5rem;
`;
export const ResultsFooter = styled.li`
list-style-type: none;
`;

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { useDebounce } from "react-use";
import { t } from "ttag";
@@ -7,15 +8,9 @@ import { EmptyState } from "metabase/common/components/EmptyState";
import { SearchResult } from "metabase/common/components/SearchResult/SearchResult";
import { useListKeyboardNavigation } from "metabase/common/hooks/use-list-keyboard-navigation";
import type { SearchFilters } from "metabase/common/search/types";
import {
EmptyStateContainer,
ResultsContainer,
ResultsFooter,
SearchResultsList,
} from "metabase/nav/components/search/SearchResults/SearchResults.styled";
import { useDispatch } from "metabase/redux";
import { push } from "metabase/router";
import { Loader } from "metabase/ui";
import { Box, Loader, Stack, rem } from "metabase/ui";
import { modelToUrl } from "metabase/urls";
import {
DEFAULT_SEARCH_LIMIT,
@@ -29,6 +24,8 @@ import type {
SearchResponse as SearchResultsType,
} from "metabase-types/api";
import S from "./SearchResults.module.css";
export type SearchResultsFooter =
| (({
metadata,
@@ -54,6 +51,18 @@ export const SearchLoadingSpinner = () => (
<Loader size="lg" label={t`Loading…`} />
);
export const EmptyStateContainer = ({
children,
"data-testid": dataTestId,
}: {
children: ReactNode;
"data-testid"?: string;
}) => (
<Box mt={rem(64)} mb="xl" data-testid={dataTestId}>
{children}
</Box>
);
export const SearchResults = ({
onEntitySelect,
forceEntitySelect = false,
@@ -132,7 +141,7 @@ export const SearchResults = ({
const { reset, getRef, cursorIndex } = useListKeyboardNavigation<
ItemType,
HTMLLIElement
HTMLElement
>({
list: dropdownItemList,
onEnter: onEnterSelect,
@@ -148,8 +157,12 @@ export const SearchResults = ({
}
return hasResults ? (
<SearchResultsList data-testid="search-results-list" gap={0}>
<ResultsContainer>
<Stack
className={S.searchResultsList}
data-testid="search-results-list"
gap={0}
>
<Box component="ul" p="sm" className={S.resultsContainer}>
{list.map((item, index) => {
const isIndexedEntity = item.model === "indexed-entity";
const onClick =
@@ -173,16 +186,16 @@ export const SearchResults = ({
</li>
);
})}
</ResultsContainer>
</Box>
{showFooter && (
<ResultsFooter ref={getRef(footerComponent)}>
<Box ref={getRef(footerComponent)}>
{footerComponent({
metadata,
isSelected: cursorIndex === dropdownItemList.length - 1,
})}
</ResultsFooter>
</Box>
)}
</SearchResultsList>
</Stack>
) : (
<EmptyStateContainer data-testid="search-results-empty-state">
<EmptyState message={t`Didn't find anything`} icon="search" />

View File

@@ -1,3 +1,6 @@
export { SearchResults, SearchLoadingSpinner } from "./SearchResults";
export {
EmptyStateContainer,
SearchLoadingSpinner,
SearchResults,
} from "./SearchResults";
export type { SearchResultsFooter } from "./SearchResults";
export * from "./SearchResults.styled";

View File

@@ -0,0 +1,16 @@
.searchResultsContainer {
display: flex;
flex-direction: column;
}
.dropdownFooter {
border-top: 1px solid var(--mb-color-border-neutral);
transition: all 0.2s ease-in-out;
}
.dropdownFooter:hover,
.dropdownFooterSelected {
color: var(--mb-color-core-brand);
background-color: var(--mb-color-background_surface-hover);
cursor: pointer;
}

View File

@@ -1,44 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { css } from "@emotion/react";
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { APP_BAR_HEIGHT } from "metabase/nav/constants";
import {
breakpointMaxSmall,
breakpointMinSmall,
} from "metabase/styled-components/theme";
import type { GroupProps, PaperProps } from "metabase/ui";
import { Group, Paper } from "metabase/ui";
// Unjustified type cast. FIXME
export const SearchResultsContainer = styled(Paper)<PaperProps>`
display: flex;
flex-direction: column;
${breakpointMaxSmall} {
height: calc(100vh - ${APP_BAR_HEIGHT});
}
${breakpointMinSmall} {
max-height: 400px;
}
` as unknown as typeof Paper;
const selectedStyles = css`
color: var(--mb-color-core-brand);
background-color: var(--mb-color-background_surface-hover);
cursor: pointer;
transition: all 0.2s ease-in-out;
`;
export const SearchDropdownFooter = styled(Group, {
shouldForwardProp: (propName) => propName !== "isSelected",
})<{ isSelected?: boolean } & GroupProps>`
border-top: 1px solid var(--mb-color-border-neutral);
${({ isSelected }) => isSelected && selectedStyles}
&:hover {
${selectedStyles}
}
`;

View File

@@ -1,14 +1,13 @@
import cx from "classnames";
import { jt, t } from "ttag";
import type { SearchResultsFooter } from "metabase/nav/components/search/SearchResults";
import { SearchResults } from "metabase/nav/components/search/SearchResults";
import { Icon, Text, rem } from "metabase/ui";
import { APP_BAR_HEIGHT } from "metabase/nav/constants";
import { Group, Icon, Paper, Text, rem } from "metabase/ui";
import type { SearchContext, SearchResult } from "metabase-types/api";
import {
SearchDropdownFooter,
SearchResultsContainer,
} from "./SearchResultsDropdown.styled";
import S from "./SearchResultsDropdown.module.css";
import { MIN_RESULTS_FOR_FOOTER_TEXT } from "./constants";
export type SearchResultsDropdownProps = {
@@ -31,25 +30,31 @@ export const SearchResultsDropdown = ({
: t`View and filter results`;
return metadata.total > 0 ? (
<SearchDropdownFooter
<Group
className={cx(S.dropdownFooter, {
[S.dropdownFooterSelected]: isSelected,
})}
role="button"
data-testid="search-dropdown-footer"
justify="space-between"
align="center"
px="lg"
py={rem(10)}
onClick={goToSearchApp}
isSelected={isSelected}
>
<Text fw={700} size="sm" c="inherit">
{resultText}
</Text>
<Icon name="arrow_right" size={14} />
</SearchDropdownFooter>
</Group>
) : null;
};
return (
<SearchResultsContainer
<Paper
className={S.searchResultsContainer}
h={{ base: `calc(100vh - ${APP_BAR_HEIGHT})`, sm: "auto" }}
mah={{ sm: rem(400) }}
data-testid="search-bar-results-container"
withBorder
>
@@ -60,6 +65,6 @@ export const SearchResultsDropdown = ({
onFooterSelect={goToSearchApp}
context={context}
/>
</SearchResultsContainer>
</Paper>
);
};

View File

@@ -90,6 +90,12 @@ export const parser = LRParser.deserialize({
"Hv~R!TOX$bXY&hYZ)dZ^&h^p$bpq&hqr*Zrs+ysw$bwx,dxy,}yz-sz{.i{|/_|}0T}!O0y!O!P1o!P!Q6X!Q!R6}!R![8u![!^$b!^!_@s!_!`Be!`!aCZ!a!c$b!c!}D{!}#O$b#O#PFa#P#R$b#R#SD{#S#T$b#T#oD{#o#y$b#y#z&h#z$f$b$f$g&h$g#BY$b#BY#BZ&h#BZ$IS$b$IS$I_&h$I_$I|$b$I|$JO&h$JO$JT$b$JT$JU&h$JU$KV$b$KV$KW&h$KW&FU$b&FU&FV&h&FV;'S$b;'S;=`&b<%lO$bU$iXyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bS%ZU|SOY%UZw%Ux#O%U#P;'S%U;'S;=`%m<%lO%US%pP;=`<%l%UQ%xUyQOY%sZr%ss#O%s#P;'S%s;'S;=`&[<%lO%sQ&_P;=`<%l%sU&eP;=`<%l$bV&qnvPyQ|SOX$bXY&hYZ(oZ^&h^p$bpq&hqr$brs%Usw$bwx%sx#O$b#P#y$b#y#z&h#z$f$b$f$g&h$g#BY$b#BY#BZ&h#BZ$IS$b$IS$I_&h$I_$I|$b$I|$JO&h$JO$JT$b$JT$JU&h$JU$KV$b$KV$KW&h$KW&FU$b&FU&FV&h&FV;'S$b;'S;=`&b<%lO$bP(tYvPX^(opq(o#y#z(o$f$g(o#BY#BZ(o$IS$I_(o$I|$JO(o$JT$JU(o$KV$KW(o&FU&FV(oV)kYzUvPX^(opq(o#y#z(o$f$g(o#BY#BZ(o$IS$I_(o$I|$JO(o$JT$JU(o$KV$KW(o&FU&FV(oV*bZyQ|SOY$bZr$brs%Usw$bwx%sx!_$b!_!`+T!`#O$b#P;'S$b;'S;=`&b<%lO$bV+^XfPyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bV,QUxR|SOY%UZw%Ux#O%U#P;'S%U;'S;=`%m<%lO%UV,kU{TyQOY%sZr%ss#O%s#P;'S%s;'S;=`&[<%lO%sV-WXhPyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bV-|XiPyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bV.rX]PyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bV/hXZPyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bV0^XmPyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bV1SX[PyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bV1vZyQ|SOY$bZr$brs%Usw$bwx%sx!Q$b!Q![2i![#O$b#P;'S$b;'S;=`&b<%lO$bV2raRPyQ|SOY$bZr$brs%Usw$bwx%sx!Q$b!Q![2i![!g$b!g!h3w!h#O$b#P#R$b#R#S2i#S#X$b#X#Y3w#Y;'S$b;'S;=`&b<%lO$bV4QaRPyQ|SOY$bZr$brs%Usw$bwx%sx{$b{|5V|}$b}!O5V!O!Q$b!Q![5V![#O$b#P#R$b#R#S5V#S;'S$b;'S;=`&b<%lO$bV5`]RPyQ|SOY$bZr$brs%Usw$bwx%sx!Q$b!Q![5V![#O$b#P#R$b#R#S5V#S;'S$b;'S;=`&b<%lO$bV6bX^PyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bV7WiRPyQ|SOY$bZr$brs%Usw$bwx%sx!O$b!O!P2i!P!Q$b!Q![8u![!g$b!g!h3w!h#O$b#P#R$b#R#S8u#S#U$b#U#V:Z#V#X$b#X#Y3w#Y#c$b#c#d=_#d#l$b#l#m?g#m;'S$b;'S;=`&b<%lO$bV9OcRPyQ|SOY$bZr$brs%Usw$bwx%sx!O$b!O!P2i!P!Q$b!Q![8u![!g$b!g!h3w!h#O$b#P#R$b#R#S8u#S#X$b#X#Y3w#Y;'S$b;'S;=`&b<%lO$bV:b^yQ|SOY$bZr$brs%Usw$bwx%sx!Q$b!Q!R;^!R!S;^!S#O$b#P#R$b#R#S;^#S;'S$b;'S;=`&b<%lO$bV;g`RPyQ|SOY$bZr$brs%Usw$bwx%sx!Q$b!Q!R;^!R!S;^!S#O$b#P#R$b#R#S;^#S#b$b#b#c<i#c;'S$b;'S;=`&b<%lO$bV<rXRPyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bV=f]yQ|SOY$bZr$brs%Usw$bwx%sx!Q$b!Q!Y>_!Y#O$b#P#R$b#R#S>_#S;'S$b;'S;=`&b<%lO$bV>h_RPyQ|SOY$bZr$brs%Usw$bwx%sx!Q$b!Q!Y>_!Y#O$b#P#R$b#R#S>_#S#b$b#b#c<i#c;'S$b;'S;=`&b<%lO$bV?nayQ|SOY$bZr$brs%Usw$bwx%sx!Q$b!Q![<i![!c$b!c!i<i!i#O$b#P#R$b#R#S<i#S#T$b#T#Z<i#Z;'S$b;'S;=`&b<%lO$bV@|ZcPyQ|SOY$bZr$brs%Usw$bwx%sx!_$b!_!`Ao!`#O$b#P;'S$b;'S;=`&b<%lO$bVAxXdPyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bVBnXePyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bVCdZaPyQ|SOY$bZr$brs%Usw$bwx%sx!_$b!_!`DV!`#O$b#P;'S$b;'S;=`&b<%lO$bVD`XbPyQ|SOY$bZr$brs%Usw$bwx%sx#O$b#P;'S$b;'S;=`&b<%lO$bVEUcWPyQ|SOY$bZr$brs%Usw$bwx%sx!O$b!O!PD{!P!Q$b!Q![D{![!c$b!c!}D{!}#O$b#P#R$b#R#SD{#S#T$b#T#oD{#o;'S$b;'S;=`&b<%lO$b~FdVO#iFy#i#jGO#j#lFy#l#mGk#m;'SFy;'S;=`Hp<%lOFy~GOOT~~GRS!Q![G_!c!iG_#T#ZG_#o#pHT~GbR!Q![Gk!c!iGk#T#ZGk~GnR!Q![Gw!c!iGw#T#ZGw~GzR!Q![Fy!c!iFy#T#ZFy~HWR!Q![Ha!c!iHa#T#ZHa~HdS!Q![Ha!c!iHa#T#ZHa#q#rFy~HsP;=`<%lFy",
tokenizers: [field, 0, 1, 2],
topRules: { Expression: [0, 2] },
specialized: [{ term: 8, get: (value) => spec_Identifier[value] || -1 }],
specialized: [
{
term: 8,
get: (value: keyof typeof spec_Identifier) =>
spec_Identifier[value] || -1,
},
],
tokenPrec: 0,
});

View File

@@ -1,22 +1,27 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import { useMemo, useState } from "react";
import { t } from "ttag";
import { getErrorMessage } from "metabase/api/utils";
import type { QuestionResultLoaderChildrenProps } from "metabase/common/components/QuestionResultLoader";
import { QuestionResultLoader } from "metabase/common/components/QuestionResultLoader";
import CS from "metabase/css/core/index.css";
import { Box, Button, Flex, Icon } from "metabase/ui";
import { checkNotNull } from "metabase/utils/types";
import Visualization from "metabase/visualizations/components/Visualization";
import * as Lib from "metabase-lib";
import Question from "metabase-lib/v1/Question";
import type { Dataset } from "metabase-types/api";
import type { NotebookStep } from "../../../types";
import S from "./NotebookStepPreview.module.css";
const PREVIEW_ROWS_LIMIT = 10;
const getPreviewQuestion = (step) => {
const { previewQuery, stageIndex } = step;
const getPreviewQuestion = (step: NotebookStep) => {
const { stageIndex } = step;
const previewQuery = checkNotNull(step.previewQuery);
const limit = Lib.currentLimit(previewQuery, stageIndex);
const hasSuitableLimit = limit !== null && limit <= PREVIEW_ROWS_LIMIT;
const queryWithLimit = hasSuitableLimit
@@ -28,7 +33,15 @@ const getPreviewQuestion = (step) => {
.setSettings({ "table.pivot": false });
};
export const NotebookStepPreview = ({ step, onClose }) => {
type NotebookStepPreviewProps = {
step: NotebookStep;
onClose: () => void;
};
export const NotebookStepPreview = ({
step,
onClose,
}: NotebookStepPreviewProps) => {
const previewQuestion = useMemo(() => getPreviewQuestion(step), [step]);
const [activeQuestion, setActiveQuestion] = useState(previewQuestion);
@@ -87,7 +100,16 @@ export const NotebookStepPreview = ({ step, onClose }) => {
);
};
export const VisualizationPreview = ({ rawSeries, result, error }) => {
type VisualizationPreviewProps = Pick<
QuestionResultLoaderChildrenProps,
"rawSeries" | "result" | "error"
>;
export const VisualizationPreview = ({
rawSeries,
result,
error,
}: VisualizationPreviewProps) => {
const errorPayload = error || result?.error;
const err = errorPayload
? getErrorMessage(errorPayload, t`Could not fetch preview`)
@@ -95,7 +117,7 @@ export const VisualizationPreview = ({ rawSeries, result, error }) => {
return (
<Visualization
rawSeries={rawSeries}
rawSeries={rawSeries ?? undefined}
error={err}
queryBuilderMode="notebook"
className={cx(
@@ -115,7 +137,7 @@ export const VisualizationPreview = ({ rawSeries, result, error }) => {
);
};
function getPreviewHeightForResult(result) {
function getPreviewHeightForResult(result: Dataset | null) {
const rowCount = result ? result.data.rows.length : 1;
return rowCount * 36 + 36 + 2;
}

View File

@@ -1,4 +1,5 @@
import { renderWithProviders, screen } from "__support__/ui";
import { createMockDataset } from "metabase-types/api/mocks";
import { VisualizationPreview } from "./NotebookStepPreview";
@@ -31,7 +32,7 @@ describe("VisualizationPreview", () => {
renderWithProviders(
<VisualizationPreview
rawSeries={null}
result={{ error: message }}
result={createMockDataset({ error: message })}
error={null}
/>,
);

View File

@@ -1,6 +1,8 @@
import type { ComponentType, ReactNode } from "react";
import { createContext, useContext } from "react";
import type { Route } from "./route";
const OutletContext = createContext<ReactNode>(null);
/**
@@ -15,6 +17,69 @@ export function Outlet(): JSX.Element {
return <>{child}</>;
}
type RouteWithElement = Route & { element?: ReactNode };
type RouteElementComponent = ComponentType<{
children?: ReactNode;
route?: RouteWithElement;
}>;
// The generated route `component` is keyed by the element's component *type*, not
// by element identity, and it renders the element off the v3-injected `route`.
// Two sibling routes that render the same component (`element={<X/>}` at
// `foo/:a` and `foo/:a/:b`) therefore share one `component`, so React reconciles
// `X` across a navigation between them instead of remounting it — matching v3's
// `component={X}` behaviour and preserving `X`'s state. It also keeps the
// component stable across v3's config rebuilds, so a `redirect()` element does
// not remount and re-fire its navigation in a loop.
const componentByType = new WeakMap<object, RouteElementComponent>();
/**
* Turns a v7-style `element` into a react-router v3 route `component`. The child
* route that v3 injects as `props.children` is exposed through context, letting
* the element render it with `<Outlet/>`. This is how `<Route element={<X/>}>`
* runs on v3.
*/
export function routeElementToComponent(
element: ReactNode,
): RouteElementComponent {
const type = elementType(element);
if (type != null) {
let cached = componentByType.get(type);
if (!cached) {
cached = makeRouteElementComponent();
componentByType.set(type, cached);
}
return cached;
}
return makeRouteElementComponent();
}
// The component type of an element, usable as a WeakMap key (a component function
// or a `React.memo`/`forwardRef` object). Host tags (strings) and fragments
// (symbols) aren't keyable, so those fall back to a fresh, unmemoized component.
function elementType(element: ReactNode): object | null {
if (element != null && typeof element === "object" && "type" in element) {
// Narrowed to an object with a `type` key just above; read it as `unknown`
// to classify the element's component type without assuming a React shape.
const type = (element as { type: unknown }).type;
if (typeof type === "function" || (typeof type === "object" && type)) {
return type;
}
}
return null;
}
function makeRouteElementComponent(): RouteElementComponent {
return function RouteElement({ children, route }) {
return (
<OutletContext.Provider value={children}>
{route?.element}
</OutletContext.Provider>
);
};
}
/**
* Wraps a v7-style component so it can be used as a react-router v3 route
* `component`. The child route that v3 injects as `props.children` is exposed
@@ -23,7 +88,7 @@ export function Outlet(): JSX.Element {
export function withOutlet(
Component: ComponentType,
): ComponentType<{ children?: ReactNode }> {
return function RouteWithOutlet({ children }) {
return function RoutedComponent({ children }) {
return (
<OutletContext.Provider value={children}>
<Component />

View File

@@ -8,9 +8,11 @@ import { getAdminPaths } from "metabase/selectors/admin";
import { getIsEmbeddingIframe } from "metabase/selectors/embed";
import { getCanAccessOnboardingPage } from "metabase/selectors/onboarding";
import { getSetting } from "metabase/selectors/settings";
import { getBasename } from "metabase/utils/basename";
import { isSameOrSiteUrlOrigin, replaceLocation } from "metabase/utils/dom";
import { Navigate } from "./Navigate";
import { Outlet } from "./Outlet";
import type { Location } from "./types";
import { useLocation } from "./use-location";
@@ -31,6 +33,37 @@ const getRedirectUrl = () => {
: "/";
};
/**
* A redirect target arrives in one of two shapes: an absolute URL
* (`https://host/metabase/x`, `//host/x`) whose pathname is already what the
* browser sees, or a basename-relative path (`/oauth/x`, sloppily `oauth/x`) —
* the convention for every SPA path, including the backend's login redirect
* (the server sits behind the prefix-stripping proxy and never sees the
* subpath). Normalize to a browser-real URL.
*/
const toBrowserUrl = (url: string) => {
const hasExplicitOrigin = /^[a-z][a-z0-9+.-]*:|^\/\//i.test(url);
// String-join for paths, not URL resolution: a leading "/" is *root*-relative
// and would discard the basename from a URL base.
return hasExplicitOrigin
? new URL(url, window.location.origin)
: new URL(
`${getBasename()}/${url.replace(/^\//, "")}`,
window.location.origin,
);
};
/** The inverse: strip the basename back off for the SPA router, which prepends
* it itself (and for prefix checks like `isBackendOnlyPath`). */
const toRouterPath = ({ pathname, search, hash }: URL) => {
const basename = getBasename();
const relativePathname =
basename && pathname.startsWith(`${basename}/`)
? pathname.slice(basename.length)
: pathname;
return `${relativePathname}${search}${hash}`;
};
/**
* `getRedirectUrl` accepts site-url-origin targets as well as same-origin ones,
* so it can hand back an absolute URL. The SPA router only understands in-app
@@ -38,13 +71,13 @@ const getRedirectUrl = () => {
* can serve that origin at all.
*/
const resolveRedirectTarget = (url: string) => {
const target = new URL(url, window.location.origin);
const target = toBrowserUrl(url);
return {
// Absolute, so a full-page redirect is not re-resolved against the current
// path (a bare `auth/sso/x` would otherwise land under `/auth/login`).
href: target.href,
path: `${target.pathname}${target.search}${target.hash}`,
path: toRouterPath(target),
isInAppOrigin: target.origin === window.location.origin,
};
};
@@ -167,49 +200,63 @@ const UserCanAccessDataStudio = createRedirectGuard(
"/unauthorized",
);
export const IsAuthenticated = ({ children }: Props) => (
<MetabaseIsSetup>
<UserIsAuthenticated>{children}</UserIsAuthenticated>
</MetabaseIsSetup>
);
export const IsAdmin = ({ children }: Props) => (
export const IsAuthenticated = () => (
<MetabaseIsSetup>
<UserIsAuthenticated>
<UserIsAdmin>{children}</UserIsAdmin>
<Outlet />
</UserIsAuthenticated>
</MetabaseIsSetup>
);
export const IsNotAuthenticated = ({ children }: Props) => (
<MetabaseIsSetup>
<UserIsNotAuthenticated>{children}</UserIsNotAuthenticated>
</MetabaseIsSetup>
);
export const CanAccessSettings = ({ children }: Props) => (
export const IsAdmin = () => (
<MetabaseIsSetup>
<UserIsAuthenticated>
<UserCanAccessSettings>{children}</UserCanAccessSettings>
<UserIsAdmin>
<Outlet />
</UserIsAdmin>
</UserIsAuthenticated>
</MetabaseIsSetup>
);
export const CanAccessOnboarding = ({ children }: Props) => (
<UserCanAccessOnboarding>{children}</UserCanAccessOnboarding>
export const IsNotAuthenticated = () => (
<MetabaseIsSetup>
<UserIsNotAuthenticated>
<Outlet />
</UserIsNotAuthenticated>
</MetabaseIsSetup>
);
export const CanAccessSettings = () => (
<MetabaseIsSetup>
<UserIsAuthenticated>
<UserCanAccessSettings>
<Outlet />
</UserCanAccessSettings>
</UserIsAuthenticated>
</MetabaseIsSetup>
);
export const CanAccessOnboarding = () => (
<UserCanAccessOnboarding>
<Outlet />
</UserCanAccessOnboarding>
);
// Must be in sync with canAccessDataStudio in frontend/src/metabase/data-studio/selectors.ts
export const CanAccessDataStudio = ({ children }: Props) => (
export const CanAccessDataStudio = () => (
<MetabaseIsSetup>
<UserIsAuthenticated>
<UserCanAccessDataStudio>
<AvailableInEmbedding>{children}</AvailableInEmbedding>
<AvailableInEmbedding>
<Outlet />
</AvailableInEmbedding>
</UserCanAccessDataStudio>
</UserIsAuthenticated>
</MetabaseIsSetup>
);
export const CanAccessDataModel = ({ children }: Props) => (
<UserCanAccessDataModel>{children}</UserCanAccessDataModel>
export const CanAccessDataModel = () => (
<UserCanAccessDataModel>
<Outlet />
</UserCanAccessDataModel>
);

View File

@@ -4,6 +4,7 @@ import {
createMockSettingsState,
createMockState,
} from "metabase/redux/store/mocks";
import { setBasename } from "metabase/utils/basename";
import { replaceLocation } from "metabase/utils/dom";
import { createMockUser } from "metabase-types/api/mocks";
@@ -37,10 +38,10 @@ describe("route-guards", () => {
const { history } = renderWithProviders(
<>
<Route component={IsAuthenticated}>
<Route element={<IsAuthenticated />}>
<Route path="/dashboard/:slug" component={Dashboard} />
</Route>
<Route component={IsNotAuthenticated}>
<Route element={<IsNotAuthenticated />}>
<Route path="/auth/login" component={LoginPage} />
</Route>
</>,
@@ -72,7 +73,7 @@ describe("route-guards", () => {
const { history } = renderWithProviders(
<>
<Route component={IsAdmin}>
<Route element={<IsAdmin />}>
<Route path="/admin/settings" component={Protected} />
</Route>
<Route path="/unauthorized" component={Unauthorized} />
@@ -100,7 +101,7 @@ describe("route-guards", () => {
const { history } = renderWithProviders(
<>
<Route component={IsNotAuthenticated}>
<Route element={<IsNotAuthenticated />}>
<Route path="/auth/login" component={LoginPage} />
</Route>
<Route path="/" component={Home} />
@@ -136,7 +137,7 @@ describe("route-guards", () => {
});
const { history } = renderWithProviders(
<Route component={IsNotAuthenticated}>
<Route element={<IsNotAuthenticated />}>
<Route path="/auth/login" component={LoginPage} />
</Route>,
{
@@ -154,6 +155,7 @@ describe("route-guards", () => {
window.history.replaceState({}, "", "/");
// reset the global MetabaseSettings singleton mutated by mockSettings
mockSettings();
setBasename("");
});
it("does a full-page redirect for a relative backend-only path", async () => {
@@ -211,6 +213,66 @@ describe("route-guards", () => {
});
expect(history?.getCurrentLocation().pathname).toBe("/auth/login");
});
describe("when Metabase is hosted under a subpath (GIT-10551)", () => {
beforeEach(() => {
setBasename("/metabase");
});
it("prefixes the subpath on a full-page redirect to a backend-only path", async () => {
const { history } = setup("/oauth/authorize?client_id=abc");
await waitFor(() => {
expect(replaceLocationMock).toHaveBeenCalledWith(
`${ORIGIN}/metabase/oauth/authorize?client_id=abc`,
);
});
expect(history?.getCurrentLocation().pathname).toBe("/auth/login");
});
it("does not double the subpath when navigating in-app", async () => {
const { history } = setup("/dashboard/1");
await waitFor(() => {
expect(history?.getCurrentLocation().pathname).toBe("/dashboard/1");
});
expect(replaceLocationMock).not.toHaveBeenCalled();
});
it("recognizes a backend-only path behind the subpath in an absolute URL", async () => {
const { history } = setup(`${ORIGIN}/metabase/oauth/authorize`);
await waitFor(() => {
expect(replaceLocationMock).toHaveBeenCalledWith(
`${ORIGIN}/metabase/oauth/authorize`,
);
});
expect(history?.getCurrentLocation().pathname).toBe("/auth/login");
});
it("does not strip a lookalike path prefix that is not the basename", async () => {
const { history } = setup(`${ORIGIN}/metabase-docs/foo`);
await waitFor(() => {
expect(history?.getCurrentLocation().pathname).toBe(
"/metabase-docs/foo",
);
});
expect(replaceLocationMock).not.toHaveBeenCalled();
});
it("handles a nested subpath basename", async () => {
setBasename("/bi/metabase");
const { history } = setup("/oauth/authorize?client_id=abc");
await waitFor(() => {
expect(replaceLocationMock).toHaveBeenCalledWith(
`${ORIGIN}/bi/metabase/oauth/authorize?client_id=abc`,
);
});
expect(history?.getCurrentLocation().pathname).toBe("/auth/login");
});
});
});
describe("isBackendOnlyPath", () => {

View File

@@ -1,5 +1,11 @@
import { type ComponentClass, type ReactElement, createElement } from "react";
import {
type ComponentClass,
type ReactElement,
type ReactNode,
createElement,
} from "react";
import { routeElementToComponent } from "./Outlet";
import {
type PlainRoute,
ReactRouterRoute,
@@ -8,11 +14,16 @@ import {
} from "./react-router";
/**
* Props accepted by the `<Route>` element. Adds react-router v7's `index` on top
* of v3's route props, so an index route is written `<Route index component={X}/>`
* instead of the v3 `<IndexRoute>`.
* Props accepted by the `<Route>` element. Adds react-router v7's `index` and
* `element` on top of v3's route props. `index` registers the route as its
* parent's index route (what v3's `<IndexRoute>` did) and works with either
* `component` or `element`. `element` is bridged to a v3 `component` that renders
* it and exposes the matched child through `<Outlet/>`.
*/
export type RouteElementProps = RouteProps & { index?: boolean };
export type RouteElementProps = RouteProps & {
index?: boolean;
element?: ReactNode;
};
interface RouteConfigElement {
(props: RouteElementProps): null;
@@ -50,7 +61,20 @@ export const Route: RouteConfigElement = Object.assign(
// Rebuild the element as a raw v3 `<Route>` so v3's own builder turns it (and
// its children) into a route object without dispatching back into this static.
const { index, ...props } = element.props;
const [route] = createRoutes(createElement(ReactRouterRoute, props));
// v3 copies our `element` prop onto the route config but `PlainRoute` does
// not type it, so widen the result to read it below.
const [route] = createRoutes(createElement(ReactRouterRoute, props)) as [
(PlainRoute & { element?: ReactNode }) | undefined,
];
// Bridge `element` onto v3: render it through a `component` that publishes
// the matched child on `<Outlet/>`'s context. The `element` stays on the
// route config so the bridge component can render it off the injected
// `route` prop, letting sibling routes that share a component reconcile
// instead of remounting. Goes away at the engine swap.
if (route?.element != null) {
route.component = routeElementToComponent(route.element);
}
if (index) {
if (parentRoute && route) {

View File

@@ -1,7 +1,9 @@
import type { PropsWithChildren } from "react";
import { type PropsWithChildren, useEffect } from "react";
import { renderWithProviders, screen } from "__support__/ui";
import { act, renderWithProviders, screen } from "__support__/ui";
import { checkNotNull } from "metabase/utils/types";
import { Outlet } from "./Outlet";
import { Route } from "./route";
const Parent = ({ children }: PropsWithChildren) => <div>{children}</div>;
@@ -42,3 +44,98 @@ describe("router/Route", () => {
expect(await screen.findByText("child-page")).toBeInTheDocument();
});
});
const Wrapper = () => (
<div>
<span>wrapper chrome</span>
<Outlet />
</div>
);
const Inner = () => (
<div>
<span>inner chrome</span>
<Outlet />
</div>
);
describe("router/Route element adapter", () => {
it("renders an `element` wrapper with the matched child exposed via <Outlet/>", async () => {
renderWithProviders(
<Route element={<Wrapper />}>
<Route path="/nested" component={ChildPage} />
</Route>,
{ withRouter: true, initialRoute: "/nested" },
);
expect(await screen.findByText("child-page")).toBeInTheDocument();
expect(screen.getByText("wrapper chrome")).toBeInTheDocument();
});
it("composes nested `element` wrappers", async () => {
renderWithProviders(
<Route element={<Wrapper />}>
<Route element={<Inner />}>
<Route path="/deep" component={ChildPage} />
</Route>
</Route>,
{ withRouter: true, initialRoute: "/deep" },
);
expect(await screen.findByText("child-page")).toBeInTheDocument();
expect(screen.getByText("wrapper chrome")).toBeInTheDocument();
expect(screen.getByText("inner chrome")).toBeInTheDocument();
});
it("still passes `component` routes straight through to v3", async () => {
renderWithProviders(<Route path="/plain" component={ChildPage} />, {
withRouter: true,
initialRoute: "/plain",
});
expect(await screen.findByText("child-page")).toBeInTheDocument();
});
});
describe("router/Route element memoization", () => {
it("reuses a shared element component across sibling routes instead of remounting it", async () => {
let mounts = 0;
const Shared = () => {
useEffect(() => {
mounts += 1;
}, []);
return (
<div>
<span>shared-chrome</span>
<Outlet />
</div>
);
};
const PageA = () => <div>page-a</div>;
const PageB = () => <div>page-b</div>;
const { history } = renderWithProviders(
<Route path="shared">
<Route path=":a" element={<Shared />}>
<Route index element={<PageA />} />
</Route>
<Route path=":a/:b" element={<Shared />}>
<Route index element={<PageB />} />
</Route>
</Route>,
{ withRouter: true, initialRoute: "/shared/1" },
);
expect(await screen.findByText("page-a")).toBeInTheDocument();
expect(mounts).toBe(1);
act(() => {
checkNotNull(history).push("/shared/1/2");
});
// The sibling route renders the same `Shared` component, so it reconciles
// across the navigation instead of remounting: `mounts` stays at 1.
expect(await screen.findByText("page-b")).toBeInTheDocument();
expect(mounts).toBe(1);
});
});

View File

@@ -107,7 +107,7 @@ export const getRoutes = (store: AppStore) => {
{/* AUTH */}
<Route path="/auth">
<Route index component={redirect("/auth/login")} />
<Route component={IsNotAuthenticated}>
<Route element={<IsNotAuthenticated />}>
<Route path="login" component={Login} />
<Route path="login/:provider" component={Login} />
</Route>
@@ -121,13 +121,13 @@ export const getRoutes = (store: AppStore) => {
</Route>
{/* MAIN */}
<Route component={IsAuthenticated}>
<Route element={<IsAuthenticated />}>
{getMetabotRoutes()}
{/* The global all hands routes, things in here are for all the folks */}
<Route path="/" component={LandingPageRedirect} />
<Route path="getting-started" component={CanAccessOnboarding}>
<Route path="getting-started" element={<CanAccessOnboarding />}>
<Route index component={Onboarding} />
</Route>
@@ -155,7 +155,7 @@ export const getRoutes = (store: AppStore) => {
})}
/>
<Route path="collection/users" component={IsAdmin}>
<Route path="collection/users" element={<IsAdmin />}>
<Route index component={UserCollectionList} />
</Route>
@@ -166,7 +166,7 @@ export const getRoutes = (store: AppStore) => {
<Route index component={PLUGIN_TENANTS.TenantCollectionList} />
</Route>
<Route path="collection/tenant-users" component={IsAdmin}>
<Route path="collection/tenant-users" element={<IsAdmin />}>
<Route index component={PLUGIN_TENANTS.TenantUsersList} />
<Route
path=":tenantId"

View File

@@ -146,7 +146,7 @@ export default function Gauge({
}}
</Pie>
<GaugeNeedle
color={getColor("background_page-tertiary-inverse")}
color={getColor("background-tertiary-inverse")}
outlineColor={outlineColor}
position={gaugeNeedlePosition}
valueAngle={gaugeNeedleAngle}

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