Compare commits

...

30 Commits

Author SHA1 Message Date
Metabase Automation
4aa474e427 Optimized 1 images, saved 55.5 KB (35.2% smaller) 2025-09-30 14:58:14 +00:00
jeff bruemmer
85049c1474 do stuff 2025-09-30 09:36:22 -04:00
jeff bruemmer
11f0cdfec3 document layout 2025-09-29 17:15:33 -04:00
John Swanson
521eed05c5 Do not URL-encode default redirect URL (#63965)
I think this was just a mistake - there's no need to URL-encode this and
it actually breaks the redirect.
2025-09-29 06:05:13 -07:00
John Swanson
e88afceba9 Index analytics collections (#64064)
These were excluded from the index, but they should really be in there.
2025-09-29 06:04:02 -07:00
John Swanson
ac2003368e When deleting DB, hide cards from search (#64112)
* When deleting DB, hide cards from search

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

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

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

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

* Use mantine props

* Use Code

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

* Cleanup log conveyance to failure :message

* Improve error feedback capability

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

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

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

* more edits

* Apply suggestions from code review

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

---------

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

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

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

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

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

* cleanup

---------

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

* Misalign icons :see-no-evil:

* Remove min width on dashboard caption input

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

* feedback

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

* e2e

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

* Params

* Params

* Params

* Params

* Params

* Rename param

* Transform params

* Transform params

* Transform params

* Transform params

* Transform params

* Transform params

* Transform params

* Transform params

* fixes

* add a test

* add a test

* add a test

* add a test

* add a test

* Fix field filters for type/TextLike

* Fix polling
2025-09-26 06:39:11 -04:00
105 changed files with 2340 additions and 950 deletions

View File

@@ -1241,6 +1241,7 @@
events
lib
models
premium-features
query-processor
settings
task

View File

@@ -115,10 +115,11 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
- name: Transforms Python Tests
test-args: >-
:only-tags [:mb/transforms-python-test]
needs-python-runner: true
# DS 2025-09-26 temporarily disabled while we diagnose flakes
# - name: Transforms Python Tests
# test-args: >-
# :only-tags [:mb/transforms-python-test]
# needs-python-runner: true
env:
CI: 'true'
DRIVERS: bigquery-cloud-sdk
@@ -982,10 +983,11 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
- name: Transforms Python Tests
test-args: >-
:only-tags [:mb/transforms-python-test]
needs-python-runner: true
# DS 2025-09-26 temporarily disabled while we diagnose flakes
# - name: Transforms Python Tests
# test-args: >-
# :only-tags [:mb/transforms-python-test]
# needs-python-runner: true
env:
CI: 'true'
DRIVERS: snowflake

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -1,9 +1,9 @@
---
title: Metabot settings
title: Metabot AI settings
summary: Configure Metabot settings, including which collections Metabot can access, and learn tips for improving Metabot's performance through data modeling and metadata.
---
# Metabot settings
# Metabot AI settings
{% include beta-blockquote.html
message="For now, <a href='https://www.metabase.com/features/metabot-ai'>Metabot</a> is only available as an add-on for Pro and Enterprise plans on Metabase Cloud."
@@ -13,21 +13,26 @@ _Settings > Admin settings > AI_
This page covers admin settings for Metabase's AI assistant, [Metabot](./metabot.md).
## Configure Metabot
To help Metabot focus on the data you care about most, select the collection containing the models and metrics you'd like it to work with.
## Pick a collection for Metabot to have access to
![Admin settings for AI Metabot](./images/ai-settings.png)
To select a collection for Metabot:
## Verified content
Admins on Pro and Enterprise plans can tell Metabot to only work with [models](../data-modeling/models.md) and [metrics](../data-modeling/metrics.md) that have been [verified](../exploration-and-organization/content-verification.md).
Restricting Metabot to verified models and metrics (and only models and metrics) can help Metabot produce more reliable answers, since you know someone has at least vetted the data Metabot can use.
## When embedding Metabot, you can pick a collection for Metabot to have access to
When embedding Metabot in your app, you can select a collection for Metabot:
1. Go to **Settings** > **Admin settings** > **AI**.
2. Click the **Collection** dropdown.
2. Click **Embedded Metabot**.
3. In the **Collection Embedded Metabot can use** section, click **Pick a collection**.
3. Select the collection that contains the models and metrics you want Metabot to use.
Metabot will use the models and metrics in that collection to help answer questions and generate queries. You can change this collection at any time.
Metabot will use the models and metrics in that collection to help answer questions and generate queries. You can change this collection at any time. To give Metabot access to all collections, you can set the collection to the root collection, called "Our Analytics" (the default).
Alternatively (or additionally), you can restrict Metabot to [verified content](#verified-content).
## Tips for making the most of Metabot
@@ -66,7 +71,7 @@ Metabase will try to set semantic types automatically, but you should confirm th
### Curate prompt suggestions
When you select a collection for Metabot to "learn", Metabot will suggest a series of prompts based on the content it finds in that collection. These give people a feel for the kinds of things people can ask Metabot to do.
When you select a collection for Metabot to "learn", Metabot will suggest a series of prompts based on the content it finds in that collection. These prompts just give people a feel for the kinds of things people can ask Metabot to do.
Admins can run these generated prompts to test the answers, or trash the individual prompts if they're not useful or misleading. You can also regenerate all the prompts with a click.

View File

@@ -83,30 +83,6 @@ redirect_from:
},
"components" : {
"schemas" : {
"api.tiles.route-params" : {
"type" : "object",
"properties" : {
"lat-field" : {
"type" : "string"
},
"lon-field" : {
"type" : "string"
},
"x" : {
"description" : "value must be an integer.",
"type" : "integer"
},
"y" : {
"description" : "value must be an integer.",
"type" : "integer"
},
"zoom" : {
"description" : "value must be an integer.",
"type" : "integer"
}
},
"required" : [ "zoom", "x", "y", "lat-field", "lon-field" ]
},
"gsheets.response" : {
"anyOf" : [ {
"type" : "object",
@@ -1984,26 +1960,52 @@ redirect_from:
"enum" : [ "none", "global-schedule" ]
},
"metabase-enterprise.transforms.api.transform-source" : {
"type" : "object",
"properties" : {
"query" : {
"type" : "object",
"properties" : {
"database" : {
"oneOf" : [ {
"type" : "object",
"properties" : {
"query" : {
"type" : "object",
"properties" : {
"database" : {
"type" : "integer"
}
},
"required" : [ "database" ]
},
"type" : {
"const" : "query"
}
},
"required" : [ "type", "query" ]
}, {
"type" : "object",
"properties" : {
"body" : {
"type" : "string"
},
"source-database" : {
"type" : "integer"
},
"source-tables" : {
"type" : "object",
"additionalProperties" : {
"type" : "integer"
}
},
"required" : [ "database" ]
"type" : {
"const" : "python"
}
},
"type" : {
"const" : "query"
}
},
"required" : [ "type", "query" ]
"required" : [ "source-tables", "type", "body" ],
"additionalProperties" : false
} ]
},
"metabase-enterprise.transforms.api.transform-target" : {
"type" : "object",
"properties" : {
"database" : {
"type" : "integer"
},
"name" : {
"type" : "string"
},
@@ -17607,11 +17609,18 @@ redirect_from:
"tags" : [ "/api/embed" ]
}
},
"/api/embed/tiles/card/{token}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/embed/tiles/card/{token}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/embed/tiles/card/{token}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/embed/tiles/card/{token}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for an embedded Card using the map visualization.",
"parameters" : [ {
"in" : "path",
"name" : "token",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
@@ -17635,27 +17644,6 @@ redirect_from:
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "token",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "parameters",
@@ -17664,6 +17652,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -17679,49 +17681,11 @@ redirect_from:
"tags" : [ "/api/embed" ]
}
},
"/api/embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a Card on an embedded Dashboard using the map visualization.",
"parameters" : [ {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "token",
"required" : true,
@@ -17746,6 +17710,30 @@ redirect_from:
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "query",
"name" : "parameters",
@@ -17754,6 +17742,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -20763,11 +20765,18 @@ redirect_from:
"tags" : [ "/api/preview_embed" ]
}
},
"/api/preview_embed/tiles/card/{token}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/preview_embed/tiles/card/{token}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/preview_embed/tiles/card/{token}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/preview_embed/tiles/card/{token}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for an embedded Card using the map visualization.",
"parameters" : [ {
"in" : "path",
"name" : "token",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
@@ -20791,27 +20800,6 @@ redirect_from:
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "token",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "parameters",
@@ -20820,6 +20808,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -20835,49 +20837,11 @@ redirect_from:
"tags" : [ "/api/preview_embed" ]
}
},
"/api/preview_embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/preview_embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/preview_embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/preview_embed/tiles/dashboard/{token}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a Card on an embedded Dashboard using the map visualization.",
"parameters" : [ {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "token",
"required" : true,
@@ -20902,6 +20866,30 @@ redirect_from:
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "query",
"name" : "parameters",
@@ -20910,6 +20898,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -21805,11 +21807,20 @@ redirect_from:
"tags" : [ "/api/public" ]
}
},
"/api/public/tiles/card/{uuid}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/public/tiles/card/{uuid}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/public/tiles/card/{uuid}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/public/tiles/card/{uuid}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a publicly-accessible Card using the map visualization. Does not require auth\n credentials. Public sharing must be enabled.",
"parameters" : [ {
"in" : "path",
"name" : "uuid",
"required" : true,
"schema" : {
"type" : "string",
"pattern" : "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
},
"description" : "value must be a valid UUID."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
@@ -21833,29 +21844,6 @@ redirect_from:
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "uuid",
"required" : true,
"schema" : {
"type" : "string",
"pattern" : "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
},
"description" : "value must be a valid UUID."
}, {
"in" : "query",
"name" : "parameters",
@@ -21864,6 +21852,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -21879,49 +21881,11 @@ redirect_from:
"tags" : [ "/api/public" ]
}
},
"/api/public/tiles/dashboard/{uuid}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/public/tiles/dashboard/{uuid}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/public/tiles/dashboard/{uuid}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/public/tiles/dashboard/{uuid}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a Card using the map visualization in a publicly-accessible Dashboard. Does not\n require auth credentials. Public sharing must be enabled.",
"parameters" : [ {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "uuid",
"required" : true,
@@ -21948,6 +21912,30 @@ redirect_from:
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "query",
"name" : "parameters",
@@ -21956,6 +21944,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -24350,11 +24352,20 @@ redirect_from:
"tags" : [ "/api/task" ]
}
},
"/api/tiles/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/tiles/{card-id}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/tiles/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/tiles/{card-id}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a saved Card.",
"parameters" : [ {
"in" : "path",
"name" : "card-id",
"required" : true,
"schema" : {
"type" : "integer",
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
@@ -24378,29 +24389,6 @@ redirect_from:
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "card-id",
"required" : true,
"schema" : {
"type" : "integer",
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "query",
"name" : "parameters",
@@ -24409,6 +24397,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -24424,49 +24426,11 @@ redirect_from:
"tags" : [ "/api/tiles" ]
}
},
"/api/tiles/{dashboard-id}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/tiles/{dashboard-id}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/tiles/{dashboard-id}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/tiles/{dashboard-id}/dashcard/{dashcard-id}/card/{card-id}/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for a dashcard.",
"parameters" : [ {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "lat-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "lon-field",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "path",
"name" : "dashboard-id",
"required" : true,
@@ -24493,6 +24457,30 @@ redirect_from:
"minimum" : 1
},
"description" : "value must be an integer greater than zero."
}, {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "query",
"name" : "parameters",
@@ -24501,6 +24489,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {
@@ -24516,11 +24518,35 @@ redirect_from:
"tags" : [ "/api/tiles" ]
}
},
"/api/tiles/{zoom}/{x}/{y}/{lat-field}/{lon-field}" : {
"/api/tiles/{zoom}/{x}/{y}" : {
"get" : {
"summary" : "GET /api/tiles/{zoom}/{x}/{y}/{lat-field}/{lon-field}",
"summary" : "GET /api/tiles/{zoom}/{x}/{y}",
"description" : "Generates a single tile image for an ad-hoc query.",
"parameters" : [ {
"in" : "path",
"name" : "zoom",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "x",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "path",
"name" : "y",
"required" : true,
"schema" : {
"type" : "integer"
},
"description" : "value must be an integer."
}, {
"in" : "query",
"name" : "query",
"required" : true,
@@ -24528,6 +24554,20 @@ redirect_from:
"type" : "string"
},
"description" : "value must be a valid JSON string."
}, {
"in" : "query",
"name" : "latField",
"required" : true,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "lonField",
"required" : true,
"schema" : {
"type" : "string"
}
} ],
"responses" : {
"2XX" : {

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 207 KiB

View File

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

View File

@@ -40,10 +40,9 @@ If you have your app running locally, and you're using the Pro Cloud version, or
## Enabling interactive embedding in Metabase
1. Go to **Settings** > **Admin settings** > **Embedding**.
2. Click **Enable**.
3. Click **Interactive embedding**.
4. Under **Authorized origins**, add the URL of the website or web app where you want to embed Metabase (such as `https://*.example.com`).
1. Go to **Admin > Embedding > Interactive**.
2. Click **Enable interactive embedding**.
3. Under **Authorized origins**, add the URL of the website or web app where you want to embed Metabase (such as `https://*.example.com`).
## Setting up embedding on your website
@@ -131,7 +130,7 @@ Note that your interactive embed must be compatible with Safari to run on _any_
If you want to embed Metabase in another domain (say, if Metabase is hosted at `metabase.yourcompany.com`, but you want to embed Metabase at `yourcompany.github.io`), you can tell Metabase to set the session cookie's SameSite value to "none".
You can set session cookie's SameSite value in **Admin settings** > **Embedding** > **Interactive embedding** > **SameSite cookie setting**.
You can set session cookie's SameSite value in **Admin settings** > **Embedding** > **Interactive** > **SameSite cookie setting**.
SameSite values include:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -296,7 +296,7 @@ This ID refers to the collection where the question was saved. In a real export,
### Entity IDs work with embedding
Metabase supports working with [Entity IDs](#metabase-uses-entity-ids-to-identify-and-reference-metabase-items) for questions, dashboards, and collections in [Static Embedding](../embedding/static-embedding.md), [Interactive embedding](../embedding/interactive-embedding.md), and the [Embedded Analytics SDK](../embedding/sdk/introduction.md).
Metabase supports working with [Entity IDs](#metabase-uses-entity-ids-to-identify-and-reference-metabase-items) for questions, dashboards, and collections in [Static Embedding](../embedding/static-embedding.md), [Embedded analytics JS](../embedding/embedded-analytics-js.md), [Interactive embedding](../embedding/interactive-embedding.md), and the [Embedded analytics SDK](../embedding/sdk/introduction.md).
A high-level workflow for using Entity IDs when embedding Metabase in your app would look something like:

View File

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

View File

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

View File

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

View File

@@ -637,6 +637,114 @@ LIMIT
});
});
describe("filtering", () => {
it("should be able to filter transforms", () => {
createMbqlTransform({ visitTransform: true });
runJobAndWaitForSuccess();
createSqlTransform({
sourceQuery: `SELECT * FROM "${TARGET_SCHEMA}"."${SOURCE_TABLE}"`,
targetTable: TARGET_TABLE_2,
visitTransform: true,
});
getTagsInput().click();
H.popover().findByText("hourly").click();
H.undoToast().findByText("Transform tags updated").should("be.visible");
visitTransformListPage();
function testLastRunDateFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
cy.log("last run at - add a filter");
getLastRunDateFilterWidget().click();
H.popover().within(() => {
cy.findByText("Relative date range…").click();
cy.findByText("Current").click();
cy.findByText("Week").click();
});
getLastRunDateFilterWidget().should("contain", "This week");
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("not.exist");
});
cy.log("last run at filter - remove filter");
getLastRunDateFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
}
function testLastRunStatusFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
cy.log("last run status - add a filter");
getLastRunStatusFilterWidget().click();
H.popover().within(() => {
cy.findByText("Success").click();
cy.findByText("Add filter").click();
});
getLastRunStatusFilterWidget().should("contain", "Success");
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("not.exist");
});
cy.log("last run status filter - remove filter");
getLastRunStatusFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
}
function testTagFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
cy.log("tags - add a filter");
getTagFilterWidget().click();
H.popover().within(() => {
cy.findByText("hourly").click();
cy.findByText("daily").click();
cy.button("Add filter").click();
});
getTagFilterWidget().should("contain", "2 tags");
getContentTable().within(() => {
cy.findByText("SQL transform").should("be.visible");
cy.findByText("MBQL transform").should("not.exist");
});
cy.log("last run status filter - remove filter");
getTagFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("MBQL transform").should("be.visible");
cy.findByText("SQL transform").should("be.visible");
});
}
testLastRunDateFilter();
testLastRunStatusFilter();
testTagFilter();
});
});
describe("name and description", () => {
it("should be able to edit the name and description after creation", () => {
createMbqlTransform({ visitTransform: true });
@@ -1116,6 +1224,7 @@ LIMIT
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: TARGET_TABLE,
schema: TARGET_SCHEMA,
},
@@ -1229,6 +1338,7 @@ LIMIT
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: TARGET_TABLE,
schema: TARGET_SCHEMA,
},
@@ -1437,6 +1547,7 @@ LIMIT
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: TARGET_TABLE,
schema: TARGET_SCHEMA,
},
@@ -1997,13 +2108,11 @@ describe("scenarios > admin > transforms > jobs", () => {
it("should be able to filter jobs ", () => {
cy.log("run hourly job so know that was recently run");
visitJobListPage();
getContentTable().findByText("Hourly job").click();
runJobAndWaitForSuccess();
visitJobListPage();
function testLastRunFilter() {
function testLastRunDateFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
@@ -2013,14 +2122,14 @@ describe("scenarios > admin > transforms > jobs", () => {
});
cy.log("last run at - add a filter");
getLastRunAtFilterWidget().click();
getLastRunDateFilterWidget().click();
H.popover().findByText("Previous month").click();
getLastRunAtFilterWidget().should("contain", "Previous month");
getLastRunDateFilterWidget().should("contain", "Previous month");
getContentTable().should("not.exist");
cy.log("last run at filter - remove filter");
getLastRunAtFilterWidget().button("Remove filter").click();
getLastRunDateFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
cy.findByText("Daily job").should("be.visible");
@@ -2029,7 +2138,42 @@ describe("scenarios > admin > transforms > jobs", () => {
});
}
function testNextRunFilter() {
function testLastRunStatusFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
cy.findByText("Daily job").should("be.visible");
cy.findByText("Weekly job").should("be.visible");
cy.findByText("Monthly job").should("be.visible");
});
cy.log("last run status - add a filter");
getLastRunStatusFilterWidget().click();
H.popover().within(() => {
cy.findByText("In progress").click();
cy.findByText("Success").click();
cy.button("Add filter").click();
});
getLastRunStatusFilterWidget().should("contain", "2 statuses");
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
cy.findByText("Daily job").should("not.exist");
cy.findByText("Weekly job").should("not.exist");
cy.findByText("Monthly job").should("not.exist");
});
cy.log("last run at filter - remove filter");
getLastRunStatusFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
cy.findByText("Daily job").should("be.visible");
cy.findByText("Weekly job").should("be.visible");
cy.findByText("Monthly job").should("be.visible");
});
}
function testNextRunDateFilter() {
cy.log("no filters");
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
@@ -2039,7 +2183,7 @@ describe("scenarios > admin > transforms > jobs", () => {
});
cy.log("next run - add a filter");
getNextRunFilterWidget().click();
getNextRunDateFilterWidget().click();
H.popover().within(() => {
cy.findByText("Fixed date range…").click();
cy.findByLabelText("Start date").clear().type("12/10/2024");
@@ -2047,14 +2191,14 @@ describe("scenarios > admin > transforms > jobs", () => {
cy.button("Apply").click();
});
getNextRunFilterWidget().should(
getNextRunDateFilterWidget().should(
"contain",
"December 10, 2024 - January 5, 2025",
);
getContentTable().should("not.exist");
cy.log("next run filter - remove filter");
getNextRunFilterWidget().button("Remove filter").click();
getNextRunDateFilterWidget().button("Remove filter").click();
getContentTable().within(() => {
cy.findByText("Hourly job").should("be.visible");
cy.findByText("Daily job").should("be.visible");
@@ -2127,8 +2271,9 @@ describe("scenarios > admin > transforms > jobs", () => {
});
}
testLastRunFilter();
testNextRunFilter();
testLastRunDateFilter();
testLastRunStatusFilter();
testNextRunDateFilter();
testTagFilter();
});
});
@@ -2617,11 +2762,15 @@ function getTransformFilterWidget() {
return cy.findByRole("group", { name: "Transform" });
}
function getLastRunAtFilterWidget() {
function getLastRunDateFilterWidget() {
return cy.findByRole("group", { name: "Last run at" });
}
function getNextRunFilterWidget() {
function getLastRunStatusFilterWidget() {
return cy.findByRole("group", { name: "Last run status" });
}
function getNextRunDateFilterWidget() {
return cy.findByRole("group", { name: "Next run at" });
}
@@ -2638,11 +2787,11 @@ function getRunMethodFilterWidget() {
}
function getStartAtFilterWidget() {
return cy.findByRole("group", { name: "Start at" });
return cy.findByRole("group", { name: "Started at" });
}
function getEndAtFilterWidget() {
return cy.findByRole("group", { name: "End at" });
return cy.findByRole("group", { name: "Ended at" });
}
function visitTransformListPage() {
@@ -2712,6 +2861,7 @@ function createMbqlTransform({
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: targetTable,
schema: targetSchema,
},
@@ -2750,6 +2900,7 @@ function createSqlTransform({
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: targetTable,
schema: targetSchema,
},

View File

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

View File

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

View File

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

View File

@@ -68,7 +68,9 @@
[:Operations
[:sequential [:map
[:op ms/NonBlankString]
[:value [:or ms/NonBlankString ms/BooleanValue]]]]]])
[:value [:or [:map-of [:or :keyword :string]
[:or ms/NonBlankString ms/BooleanValue]]
ms/NonBlankString ms/BooleanValue]]]]]])
(def SCIMGroup
"Malli schema for a SCIM group."
@@ -280,6 +282,18 @@
:status 400
:status-code 400})))))))))
(defn- patch->user-updates
[acc path value]
(if (and (nil? path) (map? value))
(reduce-kv patch->user-updates acc value)
(let [path-str (some-> path name)]
(case path-str
"active" (assoc acc :is_active (Boolean/valueOf (u/lower-case-en value)))
"userName" (assoc acc :email value)
"name.givenName" (assoc acc :first_name value)
"name.familyName" (assoc acc :last_name value)
(throw-scim-error 400 (format "Unsupported path: %s" path-str))))))
(api.macros/defendpoint :patch ["/Users/:id" :id #"[^/]+"]
"Activate or deactivate a user. Supports specific replace operations, but not arbitrary patches."
[{:keys [id]} :- [:map
@@ -292,14 +306,8 @@
updates (reduce
(fn [acc operation]
(let [{:keys [op path value]} operation]
(if (= (u/lower-case-en op) "replace")
(case path
"active" (assoc acc :is_active (Boolean/valueOf (u/lower-case-en value)))
"userName" (assoc acc :email value)
"name.givenName" (assoc acc :first_name value)
"name.familyName" (assoc acc :last_name value)
(throw-scim-error 400 (format "Unsupported path: %s" path)))
acc)))
(cond-> acc
(= (u/lower-case-en op) "replace") (patch->user-updates path value))))
{}
(:Operations patch-ops))]
(t2/update! :model/User (u/the-id user) updates)

View File

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

View File

@@ -88,11 +88,19 @@
(api.macros/defendpoint :get "/"
"Get a list of transforms."
[_route-params
_query-params]
{:keys [last_run_start_time last_run_statuses tag_ids]} :-
[:map
[:last_run_start_time {:optional true} [:maybe ms/NonBlankString]]
[:last_run_statuses {:optional true} [:maybe (ms/QueryVectorOf [:enum "started" "succeeded" "failed" "timeout"])]]
[:tag_ids {:optional true} [:maybe (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]]]
(api/check-superuser)
(-> (t2/select :model/Transform)
(t2/hydrate :last_run :transform_tag_ids)
(->> (map #(update % :last_run transforms.util/localize-run-timestamps)))))
(let [transforms (t2/select :model/Transform)]
(into []
(comp (transforms.util/->date-field-filter-xf [:last_run :start_time] last_run_start_time)
(transforms.util/->status-filter-xf [:last_run :status] last_run_statuses)
(transforms.util/->tag-filter-xf [:tag_ids] tag_ids)
(map #(update % :last_run transforms.util/localize-run-timestamps)))
(t2/hydrate transforms :last_run :transform_tag_ids))))
(api.macros/defendpoint :post "/"
"Create a new transform."

View File

@@ -8,8 +8,6 @@
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.driver.common.parameters.dates :as params.dates]
[metabase.util.date-2 :as u.date]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.jvm :as u.jvm]
[metabase.util.log :as log]
@@ -143,23 +141,6 @@
(assoc job :next_run {:start_time (str (transforms.util/->instant start-time))})
job))
(defn- matching-timestamp?
[job field-path {:keys [start end]}]
(when-let [field-instant (transforms.util/->instant (get-in job field-path))]
(let [start-instant (some-> start u.date/parse transforms.util/->instant)
end-instant (some-> end u.date/parse transforms.util/->instant)]
(and (or (nil? start)
(not (.isBefore field-instant start-instant)))
(or (nil? end)
(.isAfter end-instant field-instant))))))
(defn- ->date-field-filter-xf
[field-path filter-value]
(let [range (some-> filter-value (params.dates/date-string->range {:inclusive-end? false}))]
(if range
(filter #(matching-timestamp? % field-path range))
identity)))
(api.macros/defendpoint :get "/:job-id/transforms"
"Get the transforms of job specified by the job's ID."
[{:keys [job-id]} :- [:map
@@ -172,22 +153,21 @@
(api.macros/defendpoint :get "/"
"Get all transform jobs."
[_route-params
{:keys [last_run_start_time next_run_start_time transform_tag_ids]} :-
{:keys [last_run_start_time next_run_start_time last_run_statuses tag_ids]} :-
[:map
[:last_run_start_time {:optional true} [:maybe ms/NonBlankString]]
[:next_run_start_time {:optional true} [:maybe ms/NonBlankString]]
[:transform_tag_ids {:optional true} [:maybe (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]]]
[:last_run_statuses {:optional true} [:maybe (ms/QueryVectorOf [:enum "started" "succeeded" "failed" "timeout"])]]
[:tag_ids {:optional true} [:maybe (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]]]
(log/info "Getting all transform jobs")
(api/check-superuser)
(let [jobs (t2/select :model/TransformJob {:order-by [[:created_at :desc]]})
transform-tag-ids (-> transform_tag_ids set not-empty)]
(let [jobs (t2/select :model/TransformJob {:order-by [[:created_at :desc]]})]
(into []
(comp (map add-next-run)
(->date-field-filter-xf [:last_run :start_time] last_run_start_time)
(->date-field-filter-xf [:next_run :start_time] next_run_start_time)
(if transform-tag-ids
(filter #(some transform-tag-ids (:tag_ids %)))
identity)
(transforms.util/->date-field-filter-xf [:last_run :start_time] last_run_start_time)
(transforms.util/->date-field-filter-xf [:next_run :start_time] next_run_start_time)
(transforms.util/->status-filter-xf [:last_run :status] last_run_statuses)
(transforms.util/->tag-filter-xf [:tag_ids] tag_ids)
(map #(update % :last_run transforms.util/localize-run-timestamps))
(map #(update % :next_run transforms.util/localize-run-timestamps)))
(t2/hydrate jobs :tag_ids :last_run))))

View File

@@ -228,7 +228,7 @@
query-options (merge {:order-by order-by :offset offset :limit limit}
count-options)
runs (t2/select :model/TransformRun query-options)]
{:data (t2/hydrate runs :transform)
{:data (t2/hydrate runs [:transform :transform_tag_ids])
:limit limit
:offset offset
:total (t2/count :model/TransformRun count-options)}))

View File

@@ -7,8 +7,9 @@
[metabase-enterprise.transforms.models.transform-run :as transform-run]
[metabase-enterprise.transforms.settings :as transforms.settings]
[metabase.driver :as driver]
[metabase.driver.common.parameters.dates :as params.dates]
[metabase.lib.schema.common :as lib.schema.common]
[metabase.premium-features.core :as premium-features]
[metabase.premium-features.core :as premium-features :refer [defenterprise]]
[metabase.query-processor.compile :as qp.compile]
[metabase.query-processor.pipeline :as qp.pipeline]
[metabase.sync.core :as sync]
@@ -24,6 +25,10 @@
(set! *warn-on-reflection* true)
(def ^:const transform-temp-table-prefix
"Prefix used for temporary tables created during transform execution."
"mb_transform_temp_table")
(defn qualified-table-name
"Return the name of the target table of a transform as a possibly qualified symbol."
[_driver {:keys [schema name]}]
@@ -63,8 +68,11 @@
(throw e)))))
(defn run-cancelable-transform!
"Execute a transform with cancellation support and proper error handling."
[run-id driver {:keys [db-id conn-spec output-schema]} run-transform!]
"Execute a transform with cancellation support and proper error handling.
Options:
- `:ex-message-fn` change how caught exceptions are presented to the user in run logs, by default the same as clojure.core/ex-message"
[run-id driver {:keys [db-id conn-spec output-schema]} run-transform! & {:keys [ex-message-fn] :or {ex-message-fn ex-message}}]
;; local run is responsible for status, using canceling lifecycle
(try
(when-not (driver/schema-exists? driver db-id output-schema)
@@ -77,9 +85,7 @@
(transform-run/succeed-started-run! run-id)
ret)
(catch Throwable t
(let [{:keys [transform-run-message]} (ex-data t)
message (str/join "\n" (remove str/blank? [transform-run-message (.getMessage t)]))]
(transform-run/fail-started-run! run-id {:message message}))
(transform-run/fail-started-run! run-id {:message (ex-message-fn t)})
(throw t))
(finally
(canceling/chan-end-run! run-id))))
@@ -249,18 +255,19 @@
(driver/drop-table! driver database-id table-name))
(defn temp-table-name
"Generate a temporary table name with the given suffix and current timestamp in seconds.
If table name would exceed max table name length for the driver, fallback to using a shorter base-name."
[driver base-table-name suffix]
{:pre [(keyword? base-table-name)]}
(let [suffix (str "_" suffix "_" (quot (System/currentTimeMillis) 1000))
max-len (or (driver/table-name-length-limit driver) Integer/MAX_VALUE)
max-prefix-len (- max-len (count suffix))
table-name (name base-table-name)
prefix (subs table-name 0 (min (count table-name) max-prefix-len))]
(assert (pos? max-prefix-len))
(keyword (namespace base-table-name)
(str prefix suffix))))
"Generate a temporary table name with current timestamp in milliseconds.
If table name would exceed max table name length for the driver, fallback to using a shorter timestamp"
[driver schema]
(let [max-len (max 1 (or (driver/table-name-length-limit driver) Integer/MAX_VALUE))
timestamp (str (System/currentTimeMillis))
prefix (str transform-temp-table-prefix "_")
available (- max-len (count prefix))
;; If we don't have enough space, take the later digits of the timestamp
suffix (if (>= available (count timestamp))
timestamp
(subs timestamp (- (count timestamp) available)))
table-name (str prefix suffix)]
(keyword schema table-name)))
(defn rename-tables!
"Rename multiple tables atomically within a transaction using the new driver/rename-tables method.
@@ -269,9 +276,50 @@
(log/infof "Renaming tables: %s" (pr-str rename-map))
(driver/rename-tables! driver database-id rename-map))
(defenterprise is-temp-transform-table?
"Return true when `table` matches the transform temporary table naming pattern and transforms are enabled."
:feature :transforms
[table]
(when-let [table-name (:name table)]
(str/starts-with? (u/lower-case-en table-name) transform-temp-table-prefix)))
(defn db-routing-enabled?
"Returns whether or not the given database is either a router or destination database"
[db-or-id]
(or (t2/exists? :model/DatabaseRouter :database_id (u/the-id db-or-id))
(some->> (:router-database-id db-or-id)
(t2/exists? :model/DatabaseRouter :database_id))))
(defn- matching-timestamp?
[job field-path {:keys [start end]}]
(when-let [field-instant (->instant (get-in job field-path))]
(let [start-instant (some-> start u.date/parse ->instant)
end-instant (some-> end u.date/parse ->instant)]
(and (or (nil? start)
(not (.isBefore field-instant start-instant)))
(or (nil? end)
(.isAfter end-instant field-instant))))))
(defn ->date-field-filter-xf
"Returns an xform for a date filter."
[field-path filter-value]
(let [range (some-> filter-value (params.dates/date-string->range {:inclusive-end? false}))]
(if range
(filter #(matching-timestamp? % field-path range))
identity)))
(defn ->status-filter-xf
"Returns an xform for a transform run status filter."
[field-path statuses]
(let [statuses (->> statuses (map keyword) set not-empty)]
(if statuses
(filter #(statuses (get-in % field-path)))
identity)))
(defn ->tag-filter-xf
"Returns an xform for a transform tag filter."
[field-path tag-ids]
(let [tag-ids (-> tag-ids set not-empty)]
(if tag-ids
(filter #(some tag-ids (get-in % field-path)))
identity)))

View File

@@ -11,6 +11,8 @@
[metabase.driver :as driver]
[metabase.driver.util :as driver.u]
[metabase.util :as u]
[metabase.util.format :as u.format]
[metabase.util.i18n :as i18n]
[metabase.util.jvm :as u.jvm]
[metabase.util.log :as log]
[toucan2.core :as t2])
@@ -21,14 +23,6 @@
(set! *warn-on-reflection* true)
(def ^:const temp-table-suffix-new
"Suffix used for temporary tables containing new data during swap operations."
"new")
(def ^:const temp-table-suffix-old
"Suffix used for temporary tables containing old data during swap operations."
"old")
(defn- empty-message-log
"Returns a new message log.
@@ -41,7 +35,7 @@
[]
(atom {:pre-python [] ; log! outputs previous to the python execution, i.e table read progress
:python nil ; events json structured logs from the /logs endpoint
:post-python []})) ; log! outputs after the python execution, i.e. output reads, writes to target
:post-python []})) ; log! outputs after the python execution, i.e. output reads, writes to target
(defn- log!
"Appends a string to the message log, the string is user facing and should be suitable for presentation as part of the `transform_run.message` field."
@@ -162,8 +156,11 @@
"Transfer data using the rename-tables*! multimethod with atomicity guarantees.
Creates new table, then atomically renames target->old and new->target, then drops old."
[driver db-id table-name metadata data-source]
(let [source-table-name (transforms.util/temp-table-name driver table-name temp-table-suffix-new)
temp-table-name (transforms.util/temp-table-name driver table-name temp-table-suffix-old)]
(let [source-table-name (transforms.util/temp-table-name driver (namespace table-name))
temp-table-name (u/poll {:thunk #(transforms.util/temp-table-name driver (namespace table-name))
:done? #(not= source-table-name %)
;; Poll every 1ms to quickly generate a different timestamp-based table name
:interval-ms 1})]
(log/info "Using rename-tables strategy with atomicity guarantees")
(try
@@ -183,7 +180,7 @@
"Transfer data using create + drop + rename to minimize time without data.
Creates new table, drops old table, then renames new->target."
[driver db-id table-name metadata data-source]
(let [source-table-name (transforms.util/temp-table-name driver table-name temp-table-suffix-new)]
(let [source-table-name (transforms.util/temp-table-name driver (namespace table-name))]
(log/info "Using create-drop-rename strategy to minimize downtime")
(try
@@ -265,19 +262,18 @@
(replace-python-logs! message-log events))
(if (not= 200 status)
(throw (ex-info "Python runner call failed"
{:status-code 400
:api-status-code status
:body body
:events events
:transform-run-message (message-log->transform-run-message message-log)}))
{:transform-message (i18n/tru "Python execution failure (exit code {0})" (:exit_code body "?"))
:status-code 400
:api-status-code status
:body body
:events events}))
(try
(let [temp-file (File/createTempFile "transform-output-" ".jsonl")]
(when-not (seq (:fields output-manifest))
(throw (ex-info "No fields in metadata"
{:metadata output-manifest
:raw-body body
:events events
:transform-run-message (message-log->transform-run-message message-log)})))
:events events})))
(try
(with-open [writer (io/writer temp-file)]
(.write writer ^String output))
@@ -291,9 +287,19 @@
(catch Exception e
(log/error e "Failed to to create resulting table")
(throw (ex-info "Failed to create the resulting table"
{:transform-run-message (message-log->transform-run-message message-log)}
{:transform-message (or (:transform-message (ex-data e))
;; TODO keeping messaging the same at this level
;; should be more specific in underlying calls
(i18n/tru "Failed to create the resulting table"))}
e))))))))
(defn- exceptional-run-message [message-log ex]
(str/join "\n" (remove str/blank? [(message-log->transform-run-message message-log)
(or (:transform-message (ex-data ex))
(if (instance? InterruptedException ex)
(i18n/tru "Transform interrupted")
(i18n/tru "Something went wrong")))])))
(defn execute-python-transform!
"Execute a Python transform by calling the python runner.
@@ -306,7 +312,7 @@
{driver :engine :as db} (t2/select-one :model/Database (:database target))
{run-id :id} (transforms.util/try-start-unless-already-running transform-id run-method)]
(some-> start-promise (deliver [:started run-id]))
(log! message-log "Executing Python transform")
(log! message-log (i18n/tru "Executing Python transform"))
(log/info "Executing Python transform" transform-id "with target" (pr-str target))
(let [start-ms (u/start-timer)
transform-details {:db-id (:id db)
@@ -316,9 +322,10 @@
:output-table (transforms.util/qualified-table-name driver target)}
run-fn (fn [cancel-chan]
(run-python-transform! transform db run-id cancel-chan message-log)
(log! message-log (format "Python execution finished successfully in %s" (Duration/ofMillis (u/since-ms start-ms))))
(log! message-log (i18n/tru "Python execution finished successfully in {0}" (u.format/format-milliseconds (u/since-ms start-ms))))
(save-log-to-transform-run-message! run-id message-log))
result (transforms.util/run-cancelable-transform! run-id driver transform-details run-fn)]
ex-message-fn #(exceptional-run-message message-log %)
result (transforms.util/run-cancelable-transform! run-id driver transform-details run-fn :ex-message-fn ex-message-fn)]
(transforms.instrumentation/with-stage-timing [run-id :table-sync]
(transforms.util/sync-target! target db run-id))
{:run_id run-id

View File

@@ -14,6 +14,7 @@
;; TODO check that querying team are ok with us accessing this directly, otherwise make another plan
^{:clj-kondo/ignore [:discouraged-namespace]}
[metabase.query-processor.store :as qp.store]
[metabase.util.i18n :as i18n]
[metabase.util.json :as json]
[metabase.util.log :as log]
[toucan2.core :as t2])
@@ -246,7 +247,7 @@
table-name->id
cancel-chan]}]
;; TODO there's scope for some parallelism here, in particular across different databases
(doseq [table-id (vals table-name->id)
(doseq [[table-name table-id] table-name->id
:let [{:keys [s3-client bucket-name objects]} shared-storage
{data-path :path} (get objects [:table table-id :data])
{manifest-path :path} (get objects [:table table-id :manifest])]]
@@ -278,6 +279,13 @@
(s3/upload-file s3-client bucket-name manifest-path tmp-meta-file))
(transforms.instrumentation/record-data-transfer! run-id :file-to-s3 (+ data-size meta-size) nil)))
(catch InterruptedException ie (throw ie))
(catch Throwable t
(throw (ex-info "An error occurred while copying table data to S3"
{:table-id table-id
:transform-message (or (:transform-message (ex-data t))
(i18n/tru "Failed to copy table contents to shared storage {0} ({1})" table-name table-id))}
t)))
(finally
(safe-delete tmp-data-file)
(safe-delete tmp-meta-file))))))

View File

@@ -157,3 +157,21 @@
:last_name "Era"
:email "cam+config-file-test@metabase.com"}
(re-pattern (java.util.regex.Pattern/quote "failed: (contains? % :password)"))))
(deftest email-case-irrelevant-test
(testing "Using any case in email still works #62894"
(try
(binding [advanced-config.file/*config* {:version 1
:config {:users [{:first_name "Cam"
:last_name "Era"
:email "Cam+config-file-test@metabase.com"
:password "2cans"
:login_attributes {"a" 1}}]}}]
(is (= :ok
(advanced-config.file/initialize!)))
(testing "It should not fail with 'duplicate'"
(is (= :ok
(advanced-config.file/initialize!)))))
(finally
(t2/delete! :model/User :email "cam+config-file-test@metabase.com")
(t2/delete! :model/User :email "Cam+config-file-test@metabase.com")))))

View File

@@ -297,64 +297,127 @@
(is (= ["urn:ietf:params:scim:api:messages:2.0:Error"] (get response :schemas)))
(is (= "User not found" (get response :detail)))))))))
(deftest list-groups-test
(deftest patch-user-test-without-path
(with-scim-setup!
(mt/with-temp [:model/PermissionsGroup _group1 {:name "Group 1"}]
(testing "Fetch groups with default pagination"
(let [response (scim-client :get 200 "ee/scim/v2/Groups")]
(is (malli= scim-api/SCIMGroupList response))))
(mt/with-temp [:model/User user {:email "testuser@metabase.com"
:first_name "Test"
:last_name "User"
:is_active true
:locale "en-US"}]
(let [entity-id (t2/select-one-fn :entity_id :model/User :id (:id user))]
(testing "Deactivate an existing user"
(let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
:Operations [{:op "Replace"
:value {"active" false}}]}
response (scim-client :patch 200 (format "ee/scim/v2/Users/%s" entity-id) patch-body)]
(is (malli= scim-api/SCIMUser response))
(is (= false (:active response)))))
(testing "Fetch groups with custom pagination"
(let [response (scim-client :get 200 (format "ee/scim/v2/Groups?startIndex=%d&count=%d" 1 2))]
(is (= ["urn:ietf:params:scim:api:messages:2.0:ListResponse"] (get response :schemas)))
(is (integer? (get response :totalResults)))
(is (= 1 (get response :startIndex)))
(is (= 2 (get response :itemsPerPage)))
(is (= 2 (count (get response :Resources))))))
(testing "Reactivate an existing user"
(let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
:Operations [{:op "Replace"
:value {"active" "True"}}]}
response (scim-client :patch 200 (format "ee/scim/v2/Users/%s" entity-id) patch-body)]
(is (malli= scim-api/SCIMUser response))
(is (true? (:active response)))))
(testing "Fetch group by name"
(let [response (scim-client :get 200 (format "ee/scim/v2/Groups?filter=%s"
(codec/url-encode "displayName eq \"Group 1\"")))]
(is (malli= scim-api/SCIMGroupList response))
(is (= 1 (get response :totalResults)))
(is (= 1 (count (get response :Resources))))))
(testing "Update family name of an existing user"
(let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
:Operations [{:op "Replace"
:value {"name.familyName" "UpdatedUser"}}]}
response (scim-client :patch 200 (format "ee/scim/v2/Users/%s" entity-id) patch-body)]
(is (malli= scim-api/SCIMUser response))
(is (= "UpdatedUser" (get-in response [:name :familyName])))))
(testing "Fetch non-existant group by name"
(let [response (scim-client :get 200 (format "ee/scim/v2/Groups?filter=%s"
(codec/url-encode "displayName eq \"Fake Group\"")))]
(is (malli= scim-api/SCIMUserList response))
(is (= 0 (get response :totalResults)))
(is (= 0 (count (get response :Resources))))))
(testing "Update multiple attributes of an existing user"
(let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
:Operations [{:op "Replace"
:value {"name.givenName" "UpdatedFirstName"}}
{:op "Replace"
:value {"name.familyName" "UpdatedLastName"}}
;; Unsupported operations are ignored
{:op "Add"
:value {"name.active" "False"}}]}
response (scim-client :patch 200 (format "ee/scim/v2/Users/%s" entity-id) patch-body)]
(is (malli= scim-api/SCIMUser response))
(is (= "UpdatedFirstName" (get-in response [:name :givenName])))
(is (= "UpdatedLastName" (get-in response [:name :familyName])))
(is (true? (response :active)))))
(testing "Error if unsupported filter operation is provided"
(scim-client :get 400 (format "ee/scim/v2/Users?filter=%s"
(codec/url-encode "displayName ne \"Group 1\"")))))))
(testing "Error when using unsupported path"
(let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
:Operations [{:op "replace"
:value {"name.displayName" "unsupported"}}]}
response (scim-client :patch 400 (format "ee/scim/v2/Users/%s" entity-id) patch-body)]
(is (= ["urn:ietf:params:scim:api:messages:2.0:Error"] (get response :schemas)))
(is (= "Unsupported path: name.displayName" (get response :detail)))))
(deftest fetch-group-test
(with-scim-setup!
(testing "A single group can be fetched in the SCIM format by entity ID with its members"
(mt/with-temp [:model/PermissionsGroup group {:name "Test Group"}
:model/PermissionsGroupMembership _ {:user_id (mt/user->id :rasta) :group_id (:id group)}]
(let [entity-id (t2/select-one-fn :entity_id :model/PermissionsGroup :id (:id group))
response (scim-client :get 200 (format "ee/scim/v2/Groups/%s" entity-id))]
(is (malli= scim-api/SCIMGroup response))
(is (=?
{:schemas ["urn:ietf:params:scim:schemas:core:2.0:Group"]
:id entity-id
:displayName "Test Group"
:members [{:value (t2/select-one-fn :entity_id :model/User (mt/user->id :rasta))
:display "rasta@metabase.com"}]
:meta {:resourceType "Group"}}
response)))))
(testing "Error when trying to update a non-existent user"
(let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
:Operations [{:op "Replace"
:value {"name.familyName" "NonexistentUser"}}]}
response (scim-client :patch 404 (format "ee/scim/v2/Users/%s" (random-uuid)) patch-body)]
(is (= ["urn:ietf:params:scim:api:messages:2.0:Error"] (get response :schemas)))
(is (= "User not found" (get response :detail)))))
(testing "404 is returned when fetching a non-existant group"
(scim-client :get 404 (format "ee/scim/v2/Groups/%s" (random-uuid))))
(deftest list-groups-test
(with-scim-setup!
(mt/with-temp [:model/PermissionsGroup _group1 {:name "Group 1"}]
(testing "Fetch groups with default pagination"
(let [response (scim-client :get 200 "ee/scim/v2/Groups")]
(is (malli= scim-api/SCIMGroupList response))))
(testing "404 is returned when fetching the Admin or All Users group"
(let [entity-ids (t2/select-fn-set :entity_id :model/PermissionsGroup
{:where [:in :id #{(:id (perms-group/admin)) (:id (perms-group/all-users))}]})]
(doseq [entity-id entity-ids]
(scim-client :get 404 (format "ee/scim/v2/Groups/%s" entity-id)))))))
(testing "Fetch groups with custom pagination"
(let [response (scim-client :get 200 (format "ee/scim/v2/Groups?startIndex=%d&count=%d" 1 2))]
(is (= ["urn:ietf:params:scim:api:messages:2.0:ListResponse"] (get response :schemas)))
(is (integer? (get response :totalResults)))
(is (= 1 (get response :startIndex)))
(is (= 2 (get response :itemsPerPage)))
(is (= 2 (count (get response :Resources))))))
(testing "Fetch group by name"
(let [response (scim-client :get 200 (format "ee/scim/v2/Groups?filter=%s"
(codec/url-encode "displayName eq \"Group 1\"")))]
(is (malli= scim-api/SCIMGroupList response))
(is (= 1 (get response :totalResults)))
(is (= 1 (count (get response :Resources))))))
(testing "Fetch non-existant group by name"
(let [response (scim-client :get 200 (format "ee/scim/v2/Groups?filter=%s"
(codec/url-encode "displayName eq \"Fake Group\"")))]
(is (malli= scim-api/SCIMUserList response))
(is (= 0 (get response :totalResults)))
(is (= 0 (count (get response :Resources))))))
(testing "Error if unsupported filter operation is provided"
(scim-client :get 400 (format "ee/scim/v2/Users?filter=%s"
(codec/url-encode "displayName ne \"Group 1\"")))))))))
(deftest fetch-group-test
(with-scim-setup!
(testing "A single group can be fetched in the SCIM format by entity ID with its members"
(mt/with-temp [:model/PermissionsGroup group {:name "Test Group"}
:model/PermissionsGroupMembership _ {:user_id (mt/user->id :rasta) :group_id (:id group)}]
(let [entity-id (t2/select-one-fn :entity_id :model/PermissionsGroup :id (:id group))
response (scim-client :get 200 (format "ee/scim/v2/Groups/%s" entity-id))]
(is (malli= scim-api/SCIMGroup response))
(is (=?
{:schemas ["urn:ietf:params:scim:schemas:core:2.0:Group"]
:id entity-id
:displayName "Test Group"
:members [{:value (t2/select-one-fn :entity_id :model/User (mt/user->id :rasta))
:display "rasta@metabase.com"}]
:meta {:resourceType "Group"}}
response)))))
(testing "404 is returned when fetching a non-existant group"
(scim-client :get 404 (format "ee/scim/v2/Groups/%s" (random-uuid))))
(testing "404 is returned when fetching the Admin or All Users group"
(let [entity-ids (t2/select-fn-set :entity_id :model/PermissionsGroup
{:where [:in :id #{(:id (perms-group/admin)) (:id (perms-group/all-users))}]})]
(doseq [entity-id entity-ids]
(scim-client :get 404 (format "ee/scim/v2/Groups/%s" entity-id)))))))))
(deftest create-group-test
(with-scim-setup!

View File

@@ -105,8 +105,8 @@
(testing "listing without filtering"
(let [response (mt/user-http-request :crowberto :get 200 "ee/transform-job")]
(is (= our-job-ids (returned-job-ids response)))))
(testing "filtering by transform_tag_ids"
(let [response (mt/user-http-request :crowberto :get 200 "ee/transform-job" :transform_tag_ids [t2-id])]
(testing "filtering by tag_ids"
(let [response (mt/user-http-request :crowberto :get 200 "ee/transform-job" :tag_ids [t2-id])]
(is (=? [{:schedule "0 0 0 * * ?"
:tag_ids [t2-id]
:name "Job 3"
@@ -115,6 +115,9 @@
(testing "filtering by last_run_start_time"
(let [response (mt/user-http-request :crowberto :get 200 "ee/transform-job" :last_run_start_time "2025-08-26~")]
(is (= #{j2-id} (returned-job-ids response)))))
(testing "filtering by last_run_statuses"
(let [response (mt/user-http-request :crowberto :get 200 "ee/transform-job" :last_run_statuses ["started" "succeeded"])]
(is (= #{j2-id} (returned-job-ids response)))))
(testing "filtering by last_run_end_time without scheduled job"
(let [response (mt/user-http-request :crowberto :get 200 "ee/transform-job" :next_run_start_time "2025-08-26~")]
(is (= #{} (returned-job-ids response)))))

View File

@@ -161,6 +161,35 @@
list-resp (mt/user-http-request :crowberto :get 200 "ee/transform")]
(is (seq list-resp)))))))))
(deftest filter-transforms-test
(testing "should be able to filter transforms"
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}
(mt/with-temp [:model/Transform {t1-id :id} {}
:model/Transform {t2-id :id} {}
:model/TransformTag {tag1-id :id} {:name "tag1"}
:model/TransformTag {tag2-id :id} {:name "tag2"}
:model/TransformTransformTag _ {:transform_id t1-id :tag_id tag1-id :position 1}
:model/TransformTransformTag _ {:transform_id t2-id :tag_id tag2-id :position 1}
:model/TransformRun _ {:transform_id t1-id :status "started" :run_method "manual"
:start_time (parse-instant "2025-08-26T10:12:11")
:end_time nil
:is_active true}]
(testing "no filters"
(is (=? [{:id t1-id} {:id t2-id}]
(mt/user-http-request :crowberto :get 200 "ee/transform"))))
(testing "last_run_start_time filter"
(is (=? [{:id t1-id}]
(mt/user-http-request :crowberto :get 200 "ee/transform" :last_run_start_time "2025-08-26T10:12:11"))))
(testing "last_run_statuses filter"
(is (=? [{:id t1-id}]
(mt/user-http-request :crowberto :get 200 "ee/transform" :last_run_statuses ["started" "succeeded"]))))
(testing "tag_ids filter"
(is (=? [{:id t1-id}]
(mt/user-http-request :crowberto :get 200 "ee/transform" :tag_ids [tag1-id])))
(is (=? [{:id t2-id}]
(mt/user-http-request :crowberto :get 200 "ee/transform" :tag_ids [tag2-id])))))))))
(deftest get-transforms-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
(mt/with-premium-features #{:transforms}

View File

@@ -16,24 +16,17 @@
(let [driver driver/*driver*]
(testing "Basic table name generation"
(let [result (transforms.util/temp-table-name driver :base_table "test")]
(let [result (transforms.util/temp-table-name driver nil)
table-name (name result)]
(is (keyword? result))
(is (nil? (namespace result)))
(is (str/starts-with? (name result) "base_table_test_"))))
(testing "Table name with very large base name respects driver limits"
(let [very-long-base-name (keyword (apply str (repeat 200 "a")))
result (transforms.util/temp-table-name driver very-long-base-name "test")
max-len (or (driver/table-name-length-limit driver) Integer/MAX_VALUE)
actual-name (name result)]
(is (keyword? result))
(is (<= (count actual-name) max-len))
(is (re-find #"_test_\d+$" actual-name))))
(is (str/starts-with? table-name "mb_transform_temp_table_"))
(is (re-matches #"mb_transform_temp_table_\d+" table-name))))
(testing "Table name preserves namespace when present"
(let [result (transforms.util/temp-table-name driver :schema/base_table "test")]
(let [result (transforms.util/temp-table-name driver "schema")]
(is (= "schema" (namespace result)))
(is (str/starts-with? (name result) "base_table_test_"))))))))
(is (str/starts-with? (name result) "mb_transform_temp_table_"))))))))
(deftest temp-table-name-creates-table-test
(testing "temp-table-name produces names that can actually create tables"
@@ -41,9 +34,7 @@
(let [driver driver/*driver*
db-id (mt/id)
long-base-name (keyword (apply str (repeat 1000 "test")))
table-name (transforms.util/temp-table-name driver long-base-name "temp")
table-name (transforms.util/temp-table-name driver nil)
schema-name (when (get-method sql.tx/session-schema driver)
(sql.tx/session-schema driver))
qualified-table-name (if schema-name
@@ -62,3 +53,19 @@
(catch Exception _e
;; Ignore cleanup errors
nil)))))))))
(deftest is-temp-transform-tables-test
(testing "tables with shcema"
(let [table-with-schema {:name (name (transforms.util/temp-table-name :postgres "schema"))}
table-without-schema {:name (name (transforms.util/temp-table-name :postgres "schema"))}]
(mt/with-premium-features #{}
(is (false? (transforms.util/is-temp-transform-table? table-with-schema)))
(is (false? (transforms.util/is-temp-transform-table? table-without-schema))))
(mt/with-premium-features #{:transforms}
(is (transforms.util/is-temp-transform-table? table-without-schema))
(is (transforms.util/is-temp-transform-table? table-with-schema)))))
(testing "Ignores non-transform tables"
(mt/with-premium-features #{:transforms}
(is (false? (transforms.util/is-temp-transform-table? {:name :orders})))
(is (false? (transforms.util/is-temp-transform-table? {:name :public/orders}))))))

View File

@@ -86,12 +86,9 @@
(transforms-python.execute/execute-python-transform! transform {:run-method :manual})
(let [db-id (mt/id)
tables (t2/select :model/Table :db_id db-id :active true)
new-table-pattern (re-pattern (str ".*" table-name "_" transforms-python.execute/temp-table-suffix-new "_.*"))
old-table-pattern (re-pattern (str ".*" table-name "_" transforms-python.execute/temp-table-suffix-old "_.*"))]
(is (not-any? #(or (re-matches new-table-pattern (:name %))
(re-matches old-table-pattern (:name %))) tables)
(str "No temp tables (_" transforms-python.execute/temp-table-suffix-new "_ or _" transforms-python.execute/temp-table-suffix-old "_) should remain after successful Python transform"))
tables (t2/select :model/Table :db_id db-id :active true)]
(is (not-any? transforms.util/is-temp-transform-table? tables)
"No temp tables should remain after successful Python transform")
(is (= [[1 "a"] [2 "b"] [3 "c"]] (transforms.tu/table-rows table-name))
"Table should contain the expected data after swap")))))))))))

View File

@@ -3,6 +3,7 @@ import type {
CreateTransformRequest,
ListTransformRunsRequest,
ListTransformRunsResponse,
ListTransformsRequest,
RunTransformResponse,
Transform,
TransformId,
@@ -22,7 +23,7 @@ import {
export const transformApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
listTransforms: builder.query<Transform[], void>({
listTransforms: builder.query<Transform[], ListTransformsRequest>({
query: (params) => ({
method: "GET",
url: "/api/ee/transform",

View File

@@ -38,6 +38,7 @@ export function CollectionInstanceAnalyticsIcon({
return (
<Icon
{...iconProps}
flex="0 0 auto"
name={collectionType.icon}
tooltip={t`This is a read-only Usage Analytics ${collectionIconTooltipNameMap[entity]}.`}
data-testid="instance-analytics-collection-marker"

View File

@@ -1,5 +1,6 @@
import * as I from "icepick";
import { useCallback, useMemo } from "react";
import { match } from "ts-pattern";
import _ from "underscore";
import type { ContentTranslationFunction } from "metabase/i18n/types";
@@ -7,6 +8,7 @@ import type { HoveredObject } from "metabase/visualizations/types";
import type {
DictionaryArray,
MaybeTranslatedSeries,
RowValue,
Series,
} from "metabase-types/api";
@@ -133,9 +135,41 @@ export const translateFieldValuesInSeries = (
}
const untranslatedRows = singleSeries.data.rows.concat();
const translatedRows = singleSeries.data.rows.map((row) =>
row.map((value) => tc(value)),
);
const translatedRows: RowValue[][] = match(singleSeries.card?.display)
.with("pie", () => {
const pieRows =
singleSeries.card.visualization_settings?.["pie.rows"] ?? [];
const keyToNameMap = Object.fromEntries(
pieRows.map((row) => [row.key, row.name]),
);
// The pie chart relies on the rows to generate its legend,
// which is why we need to translate them too
// They're in the format of:
// [
// ["Doohickey", 123],
// ["Widget", 456],
// ...
// ]
//
return singleSeries.data.rows.map((row) =>
row.map((value) => {
if (
typeof value === "string" &&
keyToNameMap[value] !== undefined
) {
return tc(keyToNameMap[value]);
}
return tc(value);
}),
);
})
.otherwise(() => {
return singleSeries.data.rows.map((row) =>
row.map((value) => tc(value)),
);
});
return {
...singleSeries,
data: {

View File

@@ -0,0 +1,220 @@
import type { ContentTranslationFunction } from "metabase/i18n/types";
import type {
MaybeTranslatedSeries,
Series,
SingleSeries,
} from "metabase-types/api";
import { createMockDatasetData } from "metabase-types/api/mocks";
import { leaveUntranslated } from "./use-translate-content";
import { translateFieldValuesInSeries } from "./utils";
const mockTranslateWithTranslations: ContentTranslationFunction = (value) => {
if (typeof value === "string") {
return `translated_${value}`;
}
return value;
};
const mockTranslateWithoutTranslations: ContentTranslationFunction =
leaveUntranslated;
describe("translateFieldValuesInSeries", () => {
it("should return original series when translation function has no translations", () => {
const series: Series = [
{
data: {
rows: [
["apple", 10],
["banana", 20],
],
},
} as SingleSeries,
];
const result = translateFieldValuesInSeries(
series,
mockTranslateWithoutTranslations,
);
expect(result).toBe(series);
});
it("should return original series item when it has no data", () => {
const series: Series = [
{
card: { name: "Test Chart" },
} as SingleSeries,
];
const result = translateFieldValuesInSeries(
series,
mockTranslateWithTranslations,
);
expect(result).toEqual([
{
card: { name: "Test Chart" },
},
]);
});
it("should translate field values in regular series data", () => {
const series: Series = [
{
data: {
rows: [
["apple", 10],
["banana", 20],
["cherry", 30],
],
},
} as SingleSeries,
];
const result = translateFieldValuesInSeries(
series,
mockTranslateWithTranslations,
) as MaybeTranslatedSeries;
expect(result[0].data?.rows).toEqual([
["translated_apple", 10],
["translated_banana", 20],
["translated_cherry", 30],
]);
expect(result[0].data?.untranslatedRows).toEqual([
["apple", 10],
["banana", 20],
["cherry", 30],
]);
});
it("should handle mixed data types in rows", () => {
const series: Series = [
{
data: {
rows: [
["text", 123, null, true],
[456, "more text", false, undefined],
],
},
} as SingleSeries,
];
const result = translateFieldValuesInSeries(
series,
mockTranslateWithTranslations,
) as MaybeTranslatedSeries;
expect(result[0].data?.rows).toEqual([
["translated_text", 123, null, true],
[456, "translated_more text", false, undefined],
]);
});
it("should handle pie chart display with pie.rows visualization settings", () => {
const series: Series = [
{
card: {
display: "pie",
visualization_settings: {
"pie.rows": [
{ key: "A", name: "Apple" },
{ key: "B", name: "Banana" },
{ key: "C", name: "Cherry" },
],
},
},
data: {
rows: [
["A", 10],
["B", 20],
["C", 30],
["D", 40], // key not in pie.rows
],
},
} as SingleSeries,
];
const result = translateFieldValuesInSeries(
series,
mockTranslateWithTranslations,
) as MaybeTranslatedSeries;
expect(result[0].data?.rows).toEqual([
["translated_Apple", 10],
["translated_Banana", 20],
["translated_Cherry", 30],
["translated_D", 40], // falls back to translating the key itself
]);
});
it("should handle pie chart display without pie.rows visualization settings", () => {
const series: Series = [
{
card: {
display: "pie",
visualization_settings: {},
},
data: {
rows: [
["apple", 10],
["banana", 20],
],
},
} as SingleSeries,
];
const result = translateFieldValuesInSeries(
series,
mockTranslateWithTranslations,
) as MaybeTranslatedSeries;
expect(result[0].data?.rows).toEqual([
["translated_apple", 10],
["translated_banana", 20],
]);
});
it("should handle multiple series", () => {
const series: Series = [
{
data: {
rows: [["apple", 10]],
},
} as SingleSeries,
{
data: {
rows: [["banana", 20]],
},
} as SingleSeries,
];
const result = translateFieldValuesInSeries(
series,
mockTranslateWithTranslations,
) as MaybeTranslatedSeries;
expect(result).toHaveLength(2);
expect(result[0].data?.rows).toEqual([["translated_apple", 10]]);
expect(result[1].data?.rows).toEqual([["translated_banana", 20]]);
});
it("should handle empty rows", () => {
const series: Series = [
{
data: createMockDatasetData({
rows: [],
}),
} as SingleSeries,
];
const result = translateFieldValuesInSeries(
series,
mockTranslateWithTranslations,
) as MaybeTranslatedSeries;
expect(result[0].data?.rows).toEqual([]);
expect(result[0].data?.untranslatedRows).toEqual([]);
});
});

View File

@@ -37,7 +37,6 @@ export function InteractiveEmbeddingSettings() {
return (
<SettingsPageWrapper title={t`Interactive embedding`}>
<UpsellDevInstances location="embedding-page" />
<SettingsSection>
<EmbeddingToggle
settingKey="enable-embedding-interactive"
@@ -68,6 +67,8 @@ export function InteractiveEmbeddingSettings() {
<RelatedSettingsSection
items={getInteractiveEmbeddingRelatedSettingItems()}
/>
<UpsellDevInstances location="embedding-page" />
</SettingsPageWrapper>
);
}

View File

@@ -22,6 +22,7 @@ export const ALLOWED_EMBED_SETTING_KEYS_MAP = {
"theme",
"locale",
"preferredAuthMethod",
"fetchRequestToken",
"useExistingUserSession",
] satisfies (keyof SdkIframeEmbedBaseSettings)[],
dashboard: [
@@ -78,4 +79,5 @@ export type AllowedEmbedSettingKey =
export const DISABLE_UPDATE_FOR_KEYS = [
"instanceUrl",
"useExistingUserSession",
"fetchRequestToken",
] as const satisfies AllowedEmbedSettingKey[];

View File

@@ -412,7 +412,24 @@ export abstract class MetabaseEmbedElement extends HTMLElement {
data: Message["data"],
) {
if (this._iframe?.contentWindow) {
this._iframe.contentWindow.postMessage({ type, data }, "*");
const normalizedData = Object.entries(data).reduce(
(acc, [key, value]) => {
// Functions are not serializable, so we ignore them.
if (typeof value === "function") {
return acc;
}
acc[key as keyof typeof acc] = value;
return acc;
},
{} as Message["data"],
);
this._iframe.contentWindow.postMessage(
{ type, data: normalizedData },
"*",
);
}
}
@@ -446,7 +463,8 @@ export abstract class MetabaseEmbedElement extends HTMLElement {
* @returns {{ method: "saml" | "jwt", sessionToken: {jwt: string} }}
*/
private async _getMetabaseSessionToken() {
const { instanceUrl, preferredAuthMethod } = this.properties;
const { instanceUrl, preferredAuthMethod, fetchRequestToken } =
this.properties;
const urlResponseJson = await connectToInstanceAuthSso(instanceUrl, {
headers: this._getAuthRequestHeader(),
@@ -466,6 +484,7 @@ export abstract class MetabaseEmbedElement extends HTMLElement {
responseUrl,
instanceUrl,
this._getAuthRequestHeader(hash),
fetchRequestToken,
);
return { method, sessionToken };

View File

@@ -1,7 +1,10 @@
import type { MetabaseError } from "embedding-sdk-bundle/errors";
import type { MetabaseErrorCode } from "embedding-sdk-bundle/errors/error-code";
import type { MetabaseAuthMethod } from "embedding-sdk-bundle/types";
import type { MetabaseEmbeddingSessionToken } from "embedding-sdk-bundle/types/refresh-token";
import type {
MetabaseEmbeddingSessionToken,
MetabaseFetchRequestTokenFn,
} from "embedding-sdk-bundle/types/refresh-token";
import type {
CollectionBrowserListColumns,
EmbeddingEntityType,
@@ -140,6 +143,7 @@ export type SdkIframeEmbedBaseSettings = {
theme?: MetabaseTheme;
locale?: string;
preferredAuthMethod?: MetabaseAuthMethod;
fetchRequestToken?: MetabaseFetchRequestTokenFn;
/** Whether we should use the existing user session (i.e. admin user's cookie) */
useExistingUserSession?: boolean;
@@ -155,7 +159,10 @@ export type SdkIframeEmbedTemplateSettings =
| BrowserEmbedOptions;
/** Settings used by the sdk embed route */
export type SdkIframeEmbedSettings = SdkIframeEmbedBaseSettings &
export type SdkIframeEmbedSettings = Omit<
SdkIframeEmbedBaseSettings,
"fetchRequestToken"
> &
SdkIframeEmbedTemplateSettings;
export type SdkIframeEmbedElementSettings = SdkIframeEmbedBaseSettings &

View File

@@ -1,6 +0,0 @@
import styled from "@emotion/styled";
export const TooltipTime = styled.time`
color: var(--mb-color-text-medium);
font-size: 0.875em;
`;

View File

@@ -1,12 +1,10 @@
import { color } from "metabase/lib/colors";
import { getRelativeTime } from "metabase/lib/time-dayjs";
import { Icon, Tooltip } from "metabase/ui";
import { Icon, Text, Tooltip } from "metabase/ui";
import type { ModerationReview, User } from "metabase-types/api";
import { getIconForReview, getModeratorDisplayText } from "../../service";
import { TooltipTime } from "./ModerationReviewIcon.styled";
export interface ModerationReviewIconProps {
review: ModerationReview;
currentUser: User;
@@ -22,15 +20,21 @@ const ModerationReviewIcon = ({
const tooltip = moderator && (
<div>
<div>{getModeratorDisplayText(moderator, currentUser)}</div>
<TooltipTime dateTime={review.created_at}>
<Text
c="text-medium"
component="time"
dateTime={review.created_at}
fz="var(--mantine-font-size-xs)"
lh={1}
>
{getRelativeTime(review.created_at)}
</TooltipTime>
</Text>
</div>
);
return (
<Tooltip label={tooltip} disabled={!tooltip}>
<Icon name={iconName} color={color(iconColor)} />
<Icon color={color(iconColor)} flex="0 0 auto" name={iconName} />
</Tooltip>
);
};

View File

@@ -47,7 +47,7 @@ import {
import styles from "./SmartLinkNode.module.css";
type SmartLinkEntity =
export type SmartLinkEntity =
| Card
| Dashboard
| Collection
@@ -398,7 +398,7 @@ export const SmartLinkComponent = memo(
>
<span className={styles.smartLinkInner}>
<Icon name={iconData.name} className={styles.icon} />
{entity.name}
{getName(entity)}
</span>
</a>
</NodeViewWrapper>
@@ -427,3 +427,13 @@ function entityToObjectWithModel(
is_personal: (entity as Collection).is_personal,
};
}
function getName(entity: SmartLinkEntity) {
if ("display_name" in entity && entity.display_name !== "") {
return entity.display_name;
}
if ("name" in entity && entity.name !== "") {
return entity.name;
}
return "";
}

View File

@@ -0,0 +1,146 @@
import type { NodeViewProps } from "@tiptap/react";
import {
setupCardEndpoints,
setupCollectionByIdEndpoint,
setupDashboardEndpoints,
setupDatabaseEndpoints,
setupDocumentEndpoints,
setupTableEndpoints,
} from "__support__/server-mocks";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import type { SuggestionModel } from "metabase-enterprise/documents/components/Editor/types";
import {
createMockCard,
createMockCollection,
createMockDashboard,
createMockDatabase,
createMockDocument,
createMockTable,
} from "metabase-types/api/mocks";
import { SmartLinkComponent, type SmartLinkEntity } from "./SmartLinkNode";
function createProps(model: SuggestionModel, entity: SmartLinkEntity) {
const node = { attrs: { entityId: entity.id, model } };
return { node } as unknown as NodeViewProps;
}
function setup({
entity,
model,
}: {
model: SuggestionModel;
entity: SmartLinkEntity;
}) {
const props = createProps(model, entity);
renderWithProviders(<SmartLinkComponent {...props} />);
}
describe("SmartLink", () => {
describe("for Card", () => {
it("should render the name of a card", async () => {
const card = createMockCard({
name: "My Card",
});
setupCardEndpoints(card);
setup({ model: "card", entity: card });
await waitFor(() => {
expect(screen.getByText("My Card")).toBeInTheDocument();
});
});
});
describe("for Dashboard", () => {
it("should render the name of a dashboard", async () => {
const dashboard = createMockDashboard({
name: "My Dashboard",
});
setupDashboardEndpoints(dashboard);
setup({ model: "dashboard", entity: dashboard });
await waitFor(() => {
expect(screen.getByText("My Dashboard")).toBeInTheDocument();
});
});
});
describe("for Collection", () => {
it("should render the name of a collection", async () => {
const collection = createMockCollection({
name: "My Collection",
});
setupCollectionByIdEndpoint({ collections: [collection] });
setup({ model: "collection", entity: collection });
await waitFor(() => {
expect(screen.getByText("My Collection")).toBeInTheDocument();
});
});
});
describe("for Table", () => {
it("should render the display name of a table when possible", async () => {
const table = createMockTable({
name: "TABLE_A",
display_name: "Table A",
});
setupTableEndpoints(table);
setup({ model: "table", entity: table });
await waitFor(() => {
expect(screen.getByText("Table A")).toBeInTheDocument();
});
expect(screen.queryByText("TABLE_A")).not.toBeInTheDocument();
});
it("should fall back to the name of a table when not possible", async () => {
const table = createMockTable({
name: "TABLE_A",
display_name: "",
});
setupTableEndpoints(table);
setup({ model: "table", entity: table });
await waitFor(() => {
expect(screen.getByText("TABLE_A")).toBeInTheDocument();
});
});
});
describe("for Database", () => {
it("should render the name of a database", async () => {
const database = createMockDatabase({
name: "DATABASE_A",
});
setupDatabaseEndpoints(database);
setup({ model: "database", entity: database });
await waitFor(() => {
expect(screen.getByText("DATABASE_A")).toBeInTheDocument();
});
});
});
describe("for Document", () => {
it("should render the name of a document", async () => {
const document = createMockDocument({
name: "My Document",
});
setupDocumentEndpoints(document);
setup({ model: "document", entity: document });
await waitFor(() => {
expect(screen.getByText("My Document")).toBeInTheDocument();
});
});
});
});

View File

@@ -1,6 +1,6 @@
import { useDisclosure } from "@mantine/hooks";
import { type FormEvent, useState } from "react";
import { msgid, ngettext, t } from "ttag";
import { msgid, ngettext } from "ttag";
import {
Box,
@@ -13,9 +13,9 @@ import {
} from "metabase/ui";
import type { TransformRunStatus } from "metabase-types/api";
import { FilterFieldSet } from "../../../../components/FilterFieldSet";
import { FILTER_WIDGET_MIN_WIDTH } from "../../../../constants";
import { formatStatus } from "../../../../utils";
import { FILTER_WIDGET_MIN_WIDTH } from "../../constants";
import { formatStatus } from "../../utils";
import { FilterFieldSet } from "../FilterFieldSet";
const STATUSES: TransformRunStatus[] = [
"started",
@@ -25,11 +25,13 @@ const STATUSES: TransformRunStatus[] = [
];
type StatusFilterWidgetProps = {
label: string;
statuses: TransformRunStatus[];
onChange: (statuses: TransformRunStatus[]) => void;
};
export function StatusFilterWidget({
label,
statuses,
onChange,
}: StatusFilterWidgetProps) {
@@ -48,7 +50,7 @@ export function StatusFilterWidget({
<Popover opened={isOpened} position="bottom-start" onDismiss={close}>
<Popover.Target>
<FilterFieldSet
label={t`Status`}
label={label}
icon="check_filled"
displayValue={getDisplayValue(statuses)}
onClick={toggle}

View File

@@ -20,12 +20,14 @@ import {
import { FilterFieldSet } from "../FilterFieldSet";
type TagFilterWidgetProps = {
label: string;
tagIds: TransformTagId[];
tags: TransformTag[];
onChange: (tagIds: TransformTagId[]) => void;
};
export function TagFilterWidget({
label,
tagIds,
tags,
onChange,
@@ -45,7 +47,7 @@ export function TagFilterWidget({
<Popover opened={isOpened} position="bottom-start" onDismiss={close}>
<Popover.Target>
<FilterFieldSet
label={t`Tags`}
label={label}
icon="label"
displayValue={getDisplayValue(tagIds, tags)}
onClick={toggle}

View File

@@ -5,13 +5,18 @@ import { useDispatch } from "metabase/lib/redux";
import type { RelativeIntervalDirection } from "metabase/querying/filters/types";
import { Group } from "metabase/ui";
import { getJobListUrl } from "metabase-enterprise/transforms/urls";
import type { TransformTag, TransformTagId } from "metabase-types/api";
import type {
TransformRunStatus,
TransformTag,
TransformTagId,
} from "metabase-types/api";
import { StatusFilterWidget } from "../../../components/StatusFilterWidget";
import { TagFilterWidget } from "../../../components/TagFilterWidget";
import { TimeFilterWidget } from "../../../components/TimeFilterWidget";
import type { JobListParams } from "../../../types";
type FilterListProps = {
type JobFilterListProps = {
params: JobListParams;
tags: TransformTag[];
};
@@ -26,19 +31,25 @@ const FUTURE_INTERVAL_DIRECTIONS: RelativeIntervalDirection[] = [
"future",
];
export function JobFilterList({ params, tags }: FilterListProps) {
export function JobFilterList({ params, tags }: JobFilterListProps) {
const dispatch = useDispatch();
const handleLastRunStartTimeChange = (lastRunStartTime?: string) => {
dispatch(replace(getJobListUrl({ ...params, lastRunStartTime })));
};
const handleLastRunStatusesChange = (
lastRunStatuses?: TransformRunStatus[],
) => {
dispatch(replace(getJobListUrl({ ...params, lastRunStatuses })));
};
const handleNextRunStartTimeChange = (nextRunStartTime?: string) => {
dispatch(replace(getJobListUrl({ ...params, nextRunStartTime })));
};
const handleTagsChange = (tagIds: TransformTagId[]) => {
dispatch(replace(getJobListUrl({ ...params, transformTagIds: tagIds })));
dispatch(replace(getJobListUrl({ ...params, tagIds })));
};
return (
@@ -49,6 +60,11 @@ export function JobFilterList({ params, tags }: FilterListProps) {
availableDirections={PAST_INTERVAL_DIRECTIONS}
onChange={handleLastRunStartTimeChange}
/>
<StatusFilterWidget
label={t`Last run status`}
statuses={params.lastRunStatuses ?? []}
onChange={handleLastRunStatusesChange}
/>
<TimeFilterWidget
label={t`Next run at`}
value={params.nextRunStartTime}
@@ -56,7 +72,8 @@ export function JobFilterList({ params, tags }: FilterListProps) {
onChange={handleNextRunStartTimeChange}
/>
<TagFilterWidget
tagIds={params.transformTagIds ?? []}
label={t`Tags`}
tagIds={params.tagIds ?? []}
tags={tags}
onChange={handleTagsChange}
/>

View File

@@ -33,8 +33,9 @@ export function JobList({ params }: { params: JobListParams }) {
error: jobsError,
} = useListTransformJobsQuery({
last_run_start_time: params.lastRunStartTime,
last_run_statuses: params.lastRunStatuses,
next_run_start_time: params.nextRunStartTime,
transform_tag_ids: params.transformTagIds,
tag_ids: params.tagIds,
});
const {
data: tags = [],

View File

@@ -18,7 +18,6 @@ type JobListPageProps = {
export function JobListPage({ location }: JobListPageProps) {
const params = getParsedParams(location);
const { data: tags = [], isLoading, error } = useListTransformTagsQuery();
if (!tags || isLoading || error != null) {
@@ -41,7 +40,6 @@ export function JobListPage({ location }: JobListPageProps) {
{t`Create a job`}
</Button>
</Group>
<JobFilterList params={params} tags={tags} />
<JobList params={params} />
</Stack>

View File

@@ -1,47 +1,31 @@
import type { Location } from "history";
import { isNotNull } from "metabase/lib/types";
import type { JobListParams } from "metabase-enterprise/transforms/types";
import {
parseInteger,
parseList,
parseRunStatus,
parseString,
} from "../../utils";
export function getParsedParams(location: Location): JobListParams {
const { transformTagIds, lastRunStartTime, nextRunStartTime } =
const { lastRunStartTime, lastRunStatuses, nextRunStartTime, tagIds } =
location.query;
return {
lastRunStartTime: parseTime(lastRunStartTime),
nextRunStartTime: parseTime(nextRunStartTime),
transformTagIds: parseList(transformTagIds, parseNumber),
lastRunStartTime: parseString(lastRunStartTime),
lastRunStatuses: parseList(lastRunStatuses, parseRunStatus),
nextRunStartTime: parseString(nextRunStartTime),
tagIds: parseList(tagIds, parseInteger),
};
}
function parseTime(value: unknown): string | undefined {
if (typeof value === "string") {
return value;
}
return undefined;
}
function parseList<T>(
value: unknown,
parseItem: (value: unknown) => T | undefined,
): T[] | undefined {
if (typeof value === "string") {
const item = parseItem(value);
return item != null ? [item] : [];
}
if (Array.isArray(value)) {
return value.map(parseItem).filter(isNotNull);
}
}
function parseNumber(value: unknown) {
return typeof value === "string" ? parseInt(value, 10) : undefined;
}
export function hasFilterParams(params: JobListParams) {
return (
params.lastRunStartTime != null ||
params.lastRunStatuses != null ||
params.nextRunStartTime != null ||
params.transformTagIds != null
params.tagIds != null
);
}

View File

@@ -14,12 +14,12 @@ import type {
TransformTagId,
} from "metabase-types/api";
import { StatusFilterWidget } from "../../../components/StatusFilterWidget";
import { TagFilterWidget } from "../../../components/TagFilterWidget";
import { TimeFilterWidget } from "../../../components/TimeFilterWidget";
import type { RunListParams } from "../../../types";
import { RunMethodFilterWidget } from "./RunMethodFilterWidget";
import { StatusFilterWidget } from "./StatusFilterWidget";
import { TransformFilterWidget } from "./TransformFilterWidget";
const PAST_INTERVAL_DIRECTIONS: RelativeIntervalDirection[] = [
@@ -71,31 +71,33 @@ export function RunFilterList({
transforms={transforms}
onChange={handleTransformsChange}
/>
<StatusFilterWidget
statuses={params.statuses ?? []}
onChange={handleStatusesChange}
/>
<TagFilterWidget
tagIds={params.transformTagIds ?? []}
tags={tags}
onChange={handleTagsChange}
/>
<TimeFilterWidget
label={t`Start at`}
label={t`Started at`}
value={params.startTime}
availableDirections={PAST_INTERVAL_DIRECTIONS}
onChange={handleStartTimeChange}
/>
<TimeFilterWidget
label={t`End at`}
label={t`Ended at`}
value={params.endTime}
availableDirections={PAST_INTERVAL_DIRECTIONS}
onChange={handleEndTimeChange}
/>
<StatusFilterWidget
label={t`Status`}
statuses={params.statuses ?? []}
onChange={handleStatusesChange}
/>
<RunMethodFilterWidget
runMethods={params.runMethods ?? []}
onChange={handleRunMethodsChange}
/>
<TagFilterWidget
label={t`Tags`}
tagIds={params.transformTagIds ?? []}
tags={tags}
onChange={handleTagsChange}
/>
</Group>
);
}

View File

@@ -19,7 +19,7 @@ function setup({ params = {}, transforms = [], tags = [] }: SetupOpts = {}) {
}
describe("RunFilterList", () => {
it.each(["Start at", "End at"])(
it.each(["Started at", "Ended at"])(
"should allow only past or current date options for start and end date filters",
async (label) => {
setup();

View File

@@ -7,10 +7,11 @@ import { useSetting } from "metabase/common/hooks";
import { useDispatch } from "metabase/lib/redux";
import { Card, Flex, Group, Stack } from "metabase/ui";
import { TimezoneIndicator } from "metabase-enterprise/transforms/components/TimezoneIndicator";
import type { TransformRun } from "metabase-types/api";
import type { TransformRun, TransformTag } from "metabase-types/api";
import { ListEmptyState } from "../../../components/ListEmptyState";
import { RunStatusInfo } from "../../../components/RunStatusInfo";
import { TagList } from "../../../components/TagList";
import type { RunListParams } from "../../../types";
import { getRunListUrl, getTransformUrl } from "../../../urls";
import { formatRunMethod, parseTimestampWithTimezone } from "../../../utils";
@@ -23,9 +24,10 @@ type RunListProps = {
runs: TransformRun[];
totalCount: number;
params: RunListParams;
tags: TransformTag[];
};
export function RunList({ runs, totalCount, params }: RunListProps) {
export function RunList({ runs, totalCount, params, tags }: RunListProps) {
const { page = 0 } = params;
const hasPagination = totalCount > PAGE_SIZE;
@@ -38,7 +40,7 @@ export function RunList({ runs, totalCount, params }: RunListProps) {
return (
<Stack gap="lg">
<RunTable runs={runs} />
<RunTable runs={runs} tags={tags} />
{hasPagination && (
<Group justify="end">
<RunTablePaginationControls
@@ -55,9 +57,10 @@ export function RunList({ runs, totalCount, params }: RunListProps) {
type RunTableProps = {
runs: TransformRun[];
tags: TransformTag[];
};
function RunTable({ runs }: RunTableProps) {
function RunTable({ runs, tags }: RunTableProps) {
const systemTimezone = useSetting("system-timezone");
const dispatch = useDispatch();
@@ -77,10 +80,12 @@ function RunTable({ runs }: RunTableProps) {
<TimezoneIndicator />
</Flex>,
<Flex key="end-at" align="center" gap="xs">
<span className={S.nowrap}>{t`End at`}</span> <TimezoneIndicator />
<span className={S.nowrap}>{t`Ended at`}</span>{" "}
<TimezoneIndicator />
</Flex>,
t`Status`,
t`Trigger`,
t`Tags`,
]}
>
{runs.map((run) => (
@@ -120,6 +125,9 @@ function RunTable({ runs }: RunTableProps) {
/>
</td>
<td className={S.wrap}>{formatRunMethod(run.run_method)}</td>
<td className={S.wrap}>
<TagList tags={tags} tagIds={run.transform?.tag_ids ?? []} />
</td>
</tr>
))}
</AdminContentTable>

View File

@@ -68,14 +68,14 @@ function RunListPageBody({ params }: RunListPageBodyProps) {
run_methods: runMethods,
},
{
pollingInterval: POLLING_INTERVAL,
pollingInterval: isPolling ? POLLING_INTERVAL : undefined,
},
);
const {
data: transforms = [],
isLoading: isLoadingTransforms,
error: transformsError,
} = useListTransformsQuery();
} = useListTransformsQuery({});
const {
data: tags = [],
isLoading: isLoadingTags,
@@ -92,18 +92,21 @@ function RunListPageBody({ params }: RunListPageBodyProps) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
const shouldPoll = isPollingNeeded(data.data);
if (shouldPoll !== isPolling) {
setIsPolling(shouldPoll);
}
return (
<Stack data-testid="run-list-page">
<RunFilterList transforms={transforms} tags={tags} params={params} />
<RunList runs={data.data} totalCount={data.total} params={params} />
<RunFilterList params={params} transforms={transforms} tags={tags} />
<RunList
runs={data.data}
totalCount={data.total}
params={params}
tags={tags}
/>
</Stack>
);
}
export function isPollingNeeded(transformRuns?: TransformRun[]) {
return transformRuns?.some((run) => run.status === "started") ?? false;
export function isPollingNeeded(runs: TransformRun[] = []) {
return runs.some(
(run) => run.status === "started" || run.status === "canceling",
);
}

View File

@@ -1,11 +1,14 @@
import type { Location } from "history";
import { isNotNull } from "metabase/lib/types";
import type { RunListParams } from "metabase-enterprise/transforms/types";
import type {
TransformRunMethod,
TransformRunStatus,
} from "metabase-types/api";
import {
parseInteger,
parseList,
parseRunMethod,
parseRunStatus,
parseString,
} from "../../utils";
export function getParsedParams(location: Location): RunListParams {
const {
@@ -18,61 +21,16 @@ export function getParsedParams(location: Location): RunListParams {
runMethods,
} = location.query;
return {
page: parseNumber(page),
statuses: parseList(statuses, parseStatus),
transformIds: parseList(transformIds, parseNumber),
transformTagIds: parseList(transformTagIds, parseNumber),
startTime: parseTime(startTime),
endTime: parseTime(endTime),
runMethods: parseList(runMethods, parseMethod),
page: parseInteger(page),
statuses: parseList(statuses, parseRunStatus),
transformIds: parseList(transformIds, parseInteger),
transformTagIds: parseList(transformTagIds, parseInteger),
startTime: parseString(startTime),
endTime: parseString(endTime),
runMethods: parseList(runMethods, parseRunMethod),
};
}
function parseNumber(value: unknown) {
return typeof value === "string" ? parseInt(value, 10) : undefined;
}
function parseList<T>(
value: unknown,
parseItem: (value: unknown) => T | undefined,
): T[] | undefined {
if (typeof value === "string") {
const item = parseItem(value);
return item != null ? [item] : [];
}
if (Array.isArray(value)) {
return value.map(parseItem).filter(isNotNull);
}
}
function parseStatus(value: unknown): TransformRunStatus | undefined {
switch (value) {
case "started":
case "succeeded":
case "failed":
case "timeout":
return value;
default:
return undefined;
}
}
function parseTime(value: unknown): string | undefined {
if (typeof value === "string") {
return value;
}
return undefined;
}
function parseMethod(value: unknown): TransformRunMethod | undefined {
switch (value) {
case "manual":
case "cron":
return value;
}
return undefined;
}
export function hasFilterParams(params: RunListParams) {
return (
params.statuses != null ||

View File

@@ -0,0 +1,70 @@
import { replace } from "react-router-redux";
import { t } from "ttag";
import { useDispatch } from "metabase/lib/redux";
import type { RelativeIntervalDirection } from "metabase/querying/filters/types";
import { Group } from "metabase/ui";
import { getTransformListUrl } from "metabase-enterprise/transforms/urls";
import type {
TransformRunStatus,
TransformTag,
TransformTagId,
} from "metabase-types/api";
import { StatusFilterWidget } from "../../../components/StatusFilterWidget";
import { TagFilterWidget } from "../../../components/TagFilterWidget";
import { TimeFilterWidget } from "../../../components/TimeFilterWidget";
import type { TransformListParams } from "../../../types";
type TransformFilterListProps = {
params: TransformListParams;
tags: TransformTag[];
};
const PAST_INTERVAL_DIRECTIONS: RelativeIntervalDirection[] = [
"past",
"current",
];
export function TransformFilterList({
params,
tags,
}: TransformFilterListProps) {
const dispatch = useDispatch();
const handleLastRunStartTimeChange = (lastRunStartTime?: string) => {
dispatch(replace(getTransformListUrl({ ...params, lastRunStartTime })));
};
const handleLastRunStatusesChange = (
lastRunStatuses?: TransformRunStatus[],
) => {
dispatch(replace(getTransformListUrl({ ...params, lastRunStatuses })));
};
const handleTagsChange = (tagIds: TransformTagId[]) => {
dispatch(replace(getTransformListUrl({ ...params, tagIds })));
};
return (
<Group>
<TimeFilterWidget
label={t`Last run at`}
value={params.lastRunStartTime}
availableDirections={PAST_INTERVAL_DIRECTIONS}
onChange={handleLastRunStartTimeChange}
/>
<StatusFilterWidget
label={t`Last run status`}
statuses={params.lastRunStatuses ?? []}
onChange={handleLastRunStatusesChange}
/>
<TagFilterWidget
label={t`Tags`}
tagIds={params.tagIds ?? []}
tags={tags}
onChange={handleTagsChange}
/>
</Group>
);
}

View File

@@ -0,0 +1,31 @@
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "__support__/ui";
import type { TransformListParams } from "metabase-enterprise/transforms/types";
import type { TransformTag } from "metabase-types/api";
import { TransformFilterList } from "./TransformFilterList";
type SetupOpts = {
params?: TransformListParams;
tags?: TransformTag[];
};
function setup({ params = {}, tags = [] }: SetupOpts = {}) {
renderWithProviders(<TransformFilterList params={params} tags={tags} />);
}
describe("TransformFilterList", () => {
it("should allow only past or current date options for the last run filter", async () => {
setup();
await userEvent.click(screen.getByText("Last run at"));
expect(await screen.findByText("Today")).toBeInTheDocument();
expect(screen.getByText("Yesterday")).toBeInTheDocument();
await userEvent.click(screen.getByText("Relative date range…"));
expect(await screen.findByText("Previous")).toBeInTheDocument();
expect(screen.getByText("Current")).toBeInTheDocument();
expect(screen.queryByText("Next")).not.toBeInTheDocument();
});
});

View File

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

View File

@@ -16,18 +16,28 @@ import type { Transform } from "metabase-types/api";
import { ListEmptyState } from "../../../components/ListEmptyState";
import { RunStatusInfo } from "../../../components/RunStatusInfo";
import { TagList } from "../../../components/TagList";
import type { TransformListParams } from "../../../types";
import { getTransformUrl } from "../../../urls";
import { parseTimestampWithTimezone } from "../../../utils";
import { hasFilterParams } from "../utils";
import S from "./TransformList.module.css";
export function TransformList() {
type TransformListProps = {
params: TransformListParams;
};
export function TransformList({ params }: TransformListProps) {
const systemTimezone = useSetting("system-timezone");
const {
data: transforms = [],
isLoading: isLoadingTransforms,
error: transformsError,
} = useListTransformsQuery();
} = useListTransformsQuery({
last_run_start_time: params.lastRunStartTime,
last_run_statuses: params.lastRunStatuses,
tag_ids: params.tagIds,
});
const {
data: tags = [],
isLoading: isLoadingTags,
@@ -46,7 +56,12 @@ export function TransformList() {
}
if (transforms.length === 0) {
return <ListEmptyState label={t`No transforms yet`} />;
const hasFilters = hasFilterParams(params);
return (
<ListEmptyState
label={hasFilters ? t`No transforms found` : t`No transforms yet`}
/>
);
}
return (

View File

@@ -1,11 +1,27 @@
import type { Location } from "history";
import { t } from "ttag";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { Box, Group, Stack, Title } from "metabase/ui";
import { useListTransformTagsQuery } from "metabase-enterprise/api";
import { CreateTransformMenu } from "./CreateTransformMenu";
import { TransformFilterList } from "./TransformFilterList";
import { TransformList } from "./TransformList";
import { getParsedParams } from "./utils";
type TransformListPageProps = {
location: Location;
};
export function TransformListPage({ location }: TransformListPageProps) {
const params = getParsedParams(location);
const { data: tags = [], isLoading, error } = useListTransformTagsQuery();
if (!tags || isLoading || error != null) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
export function TransformListPage() {
return (
<Stack gap="xl" data-testid="transform-list-page">
<Group justify="space-between" align="start">
@@ -15,7 +31,8 @@ export function TransformListPage() {
</Stack>
<CreateTransformMenu />
</Group>
<TransformList />
<TransformFilterList params={params} tags={tags} />
<TransformList params={params} />
</Stack>
);
}

View File

@@ -0,0 +1,28 @@
import type { Location } from "history";
import type { TransformListParams } from "metabase-enterprise/transforms/types";
import {
parseInteger,
parseList,
parseRunStatus,
parseString,
} from "../../utils";
export function getParsedParams(location: Location): TransformListParams {
const { lastRunStartTime, lastRunStatuses, tagIds } = location.query;
return {
lastRunStartTime: parseString(lastRunStartTime),
lastRunStatuses: parseList(lastRunStatuses, parseRunStatus),
tagIds: parseList(tagIds, parseInteger),
};
}
export function hasFilterParams(params: TransformListParams) {
return (
params.lastRunStartTime != null ||
params.lastRunStatuses != null ||
params.tagIds != null
);
}

View File

@@ -5,6 +5,19 @@ import type {
TransformTagId,
} from "metabase-types/api";
export type TransformListParams = {
lastRunStartTime?: string;
lastRunStatuses?: TransformRunStatus[];
tagIds?: TransformTagId[];
};
export type JobListParams = {
lastRunStartTime?: string;
lastRunStatuses?: TransformRunStatus[];
nextRunStartTime?: string;
tagIds?: TransformTagId[];
};
export type RunListParams = {
page?: number;
statuses?: TransformRunStatus[];
@@ -14,9 +27,3 @@ export type RunListParams = {
endTime?: string;
runMethods?: TransformRunMethod[];
};
export type JobListParams = {
lastRunStartTime?: string;
nextRunStartTime?: string;
transformTagIds?: TransformTagId[];
};

View File

@@ -7,12 +7,35 @@ import type {
TransformJobId,
} from "metabase-types/api";
import type { JobListParams, RunListParams } from "./types";
import type {
JobListParams,
RunListParams,
TransformListParams,
} from "./types";
export const ROOT_URL = "/admin/transforms";
export function getTransformListUrl() {
return ROOT_URL;
export function getTransformListUrl({
lastRunStartTime,
lastRunStatuses,
tagIds,
}: TransformListParams = {}) {
const searchParams = new URLSearchParams();
if (lastRunStartTime != null) {
searchParams.set("lastRunStartTime", lastRunStartTime);
}
lastRunStatuses?.forEach((status) => {
searchParams.append("lastRunStatuses", status);
});
tagIds?.forEach((tagId) => {
searchParams.append("tagIds", String(tagId));
});
const queryString = searchParams.toString();
if (queryString.length > 0) {
return `${ROOT_URL}?${queryString}`;
} else {
return ROOT_URL;
}
}
export function getNewTransformFromTypeUrl(
@@ -35,18 +58,22 @@ export function getTransformQueryUrl(transformId: TransformId) {
export function getJobListUrl({
lastRunStartTime,
lastRunStatuses,
nextRunStartTime,
transformTagIds,
tagIds,
}: JobListParams = {}) {
const searchParams = new URLSearchParams();
if (lastRunStartTime != null) {
searchParams.set("lastRunStartTime", lastRunStartTime);
}
lastRunStatuses?.forEach((status) => {
searchParams.append("lastRunStatuses", status);
});
if (nextRunStartTime != null) {
searchParams.set("nextRunStartTime", nextRunStartTime);
}
transformTagIds?.forEach((tagId) => {
searchParams.append("transformTagIds", String(tagId));
tagIds?.forEach((tagId) => {
searchParams.append("tagIds", String(tagId));
});
const queryString = searchParams.toString();
if (queryString.length > 0) {

View File

@@ -2,6 +2,7 @@ import { t } from "ttag";
import { hasFeature } from "metabase/admin/databases/utils";
import { parseTimestamp } from "metabase/lib/time-dayjs";
import { isNotNull } from "metabase/lib/types";
import type {
Database,
DatabaseId,
@@ -76,3 +77,47 @@ export function sourceDatabaseId(source: TransformSource): DatabaseId | null {
return null;
}
export function parseList<T>(
value: unknown,
parseItem: (value: unknown) => T | undefined,
): T[] | undefined {
if (typeof value === "string") {
const item = parseItem(value);
return item != null ? [item] : [];
}
if (Array.isArray(value)) {
return value.map(parseItem).filter(isNotNull);
}
return undefined;
}
export function parseInteger(value: unknown) {
return typeof value === "string" ? parseInt(value, 10) : undefined;
}
export function parseString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function parseRunStatus(value: unknown): TransformRunStatus | undefined {
switch (value) {
case "started":
case "succeeded":
case "failed":
case "timeout":
return value;
default:
return undefined;
}
}
export function parseRunMethod(value: unknown): TransformRunMethod | undefined {
switch (value) {
case "manual":
case "cron":
return value;
default:
return undefined;
}
}

View File

@@ -140,10 +140,17 @@ export type RunTransformResponse = {
message?: string;
};
export type ListTransformsRequest = {
last_run_start_time?: string;
last_run_statuses?: TransformRunStatus[];
tag_ids?: TransformTagId[];
};
export type ListTransformJobsRequest = {
last_run_start_time?: string;
last_run_statuses?: TransformRunStatus[];
next_run_start_time?: string;
transform_tag_ids?: TransformTagId[];
tag_ids?: TransformTagId[];
};
export type ListTransformRunsRequest = {

View File

@@ -139,8 +139,6 @@ export function EmbeddingSdkSettings() {
return (
<SettingsPageWrapper title={t`Modular embedding`}>
<UpsellDevInstances location="embedding-page" />
<SdkSettingsCard
title={t`Embedded Analytics JS`}
description={t`An easy-to-use library that lets you embed Metabase entities like charts, dashboards, or even the query builder into your own application using customizable components.`}
@@ -268,6 +266,8 @@ export function EmbeddingSdkSettings() {
<RelatedSettingsSection
items={getModularEmbeddingRelatedSettingItems()}
/>
<UpsellDevInstances location="embedding-page" />
</SettingsPageWrapper>
);
}

View File

@@ -24,7 +24,6 @@ export function StaticEmbeddingSettings() {
return (
<SettingsPageWrapper title={t`Static embedding`}>
<UpsellDevInstances location="embedding-page" />
<SettingsSection>
<EmbeddingToggle
settingKey="enable-embedding-static"
@@ -42,6 +41,7 @@ export function StaticEmbeddingSettings() {
<PLUGIN_CONTENT_TRANSLATION.ContentTranslationConfiguration />
<RelatedSettingsSection items={getStaticEmbeddingRelatedSettingItems()} />
<UpsellDevInstances location="embedding-page" />
</SettingsPageWrapper>
);
}

View File

@@ -126,7 +126,7 @@
font-size: 1.25rem;
font-weight: 700;
line-height: 1.5rem;
width: 100%;
max-width: 100%;
overflow-wrap: break-word;
}

View File

@@ -145,16 +145,26 @@ export function DashboardHeaderView({
[S.showSubHeader]: showSubHeader,
})}
>
<Flex className={S.HeaderCaptionContainer}>
<Flex className={S.HeaderCaptionContainer} gap={2}>
<Dashboard.Title className={S.HeaderCaption} />
<PLUGIN_MODERATION.EntityModerationIcon
dashboard={dashboard}
/>
<PLUGIN_COLLECTION_COMPONENTS.CollectionInstanceAnalyticsIcon
color="brand"
collection={collection}
entity="dashboard"
/>
<Flex
align="center"
flex="0 0 auto"
gap="sm"
pos="relative"
// intentionally misaligned: https://github.com/metabase/metabase/pull/63871#pullrequestreview-3259596723
top={2}
>
<PLUGIN_MODERATION.EntityModerationIcon
dashboard={dashboard}
/>
<PLUGIN_COLLECTION_COMPONENTS.CollectionInstanceAnalyticsIcon
color="brand"
collection={collection}
entity="dashboard"
/>
</Flex>
</Flex>
<Flex className={S.HeaderBadges}>
{isLastEditInfoVisible && (

View File

@@ -32,3 +32,11 @@
max-height: 560px;
overflow: auto;
}
.json {
max-height: 200px;
width: 100%;
background-color: var(--mb-color-bg-light);
border: 1px solid var(--mb-color-border);
border-radius: 4px;
}

View File

@@ -1,10 +1,12 @@
import cx from "classnames";
import type { ReactNode } from "react";
import { type ReactNode, useMemo } from "react";
import { Link } from "react-router";
import { t } from "ttag";
import { CodeEditor } from "metabase/common/components/CodeEditor";
import { Box, Image, Stack, Text, rem } from "metabase/ui";
import { isFK } from "metabase-lib/v1/types/utils/isa";
import { TYPE } from "metabase-lib/v1/types/constants";
import { isFK, isa } from "metabase-lib/v1/types/utils/isa";
import type { DatasetColumn, Field, RowValue } from "metabase-types/api";
import S from "./Value.module.css";
@@ -27,11 +29,23 @@ export const Value = ({ children, column, field, value }: Props) => {
column.settings.link_text &&
column.settings.link_url,
);
const json = useMemo(() => getJson(column, value), [column, value]);
if (isEmptyValue) {
return <Text c="text-light">{t`empty`}</Text>;
}
if (json) {
return (
<CodeEditor
className={S.json}
language="json"
lineNumbers={false}
value={json}
/>
);
}
if (isFK(column) && newTableId != null && !isValidLink) {
return (
<Text
@@ -83,3 +97,34 @@ export const Value = ({ children, column, field, value }: Props) => {
</Text>
);
};
function getJson(
column: DatasetColumn,
value: RowValue | undefined,
): string | undefined {
if (!value || typeof value === "number" || typeof value === "boolean") {
return undefined;
}
if (
column.semantic_type &&
isa(column.semantic_type, TYPE.SerializedJSON) &&
typeof value == "string"
) {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch (error) {
return undefined;
}
}
if (typeof value === "object") {
try {
return JSON.stringify(value, null, 2);
} catch (error) {
return undefined;
}
}
return undefined;
}

View File

@@ -20,7 +20,7 @@ function SavedQuestionHeaderButton({
return (
<Flex
align="center"
gap="0.25rem"
gap={2}
className={SavedQuestionHeaderButtonS.HeaderTitleContainer}
>
<EditableText
@@ -33,7 +33,17 @@ function SavedQuestionHeaderButton({
data-testid="saved-question-header-title"
/>
<CollectionIcon collection={question.collection()} question={question} />
<Flex
flex="0 0 auto"
pos="relative"
// intentionally misaligned: https://github.com/metabase/metabase/pull/63871#pullrequestreview-3259596723
top={2}
>
<CollectionIcon
collection={question.collection()}
question={question}
/>
</Flex>
</Flex>
);
}

View File

@@ -1,11 +1,6 @@
import { Group } from "@visx/group";
import type { StaticChartProps } from "metabase/static-viz/components/StaticVisualization";
import {
getColumnValueStaticFormatter,
getLabelsStaticFormatter,
getStaticFormatters,
} from "metabase/static-viz/lib/format";
import { measureTextWidth } from "metabase/static-viz/lib/text";
import { extractRemappedColumns } from "metabase/visualizations";
import { getChartGoal } from "metabase/visualizations/lib/settings/goal";
@@ -21,6 +16,11 @@ import {
} from "metabase/visualizations/shared/utils/data";
import { getTwoDimensionalChartSeries } from "metabase/visualizations/shared/utils/series";
import type { RemappingHydratedChartData } from "metabase/visualizations/types";
import {
getColumnValueFormatter,
getFormatters,
getLabelsFormatter,
} from "metabase/visualizations/visualizations/RowChart/utils/format";
import { getLegendItems } from "metabase/visualizations/visualizations/RowChart/utils/legend";
import {
getAxesVisibility,
@@ -67,7 +67,7 @@ export const StaticRowChart = ({
rawSeries[0].data,
) as RemappingHydratedChartData;
const { getColor } = renderingContext;
const columnValueFormatter = getColumnValueStaticFormatter();
const columnValueFormatter = getColumnValueFormatter();
const { chartColumns, series, seriesColors } = getTwoDimensionalChartSeries(
data,
@@ -80,12 +80,12 @@ export const StaticRowChart = ({
settings,
columnValueFormatter,
);
const labelsFormatter = getLabelsStaticFormatter(chartColumns, settings);
const labelsFormatter = getLabelsFormatter(chartColumns, settings);
const goal = getChartGoal(settings);
const theme = getStaticChartTheme(getColor);
const stackOffset = getStackOffset(settings);
const tickFormatters = getStaticFormatters(chartColumns, settings);
const tickFormatters = getFormatters(chartColumns, settings);
const { xLabel, yLabel } = getLabels(settings);

View File

@@ -1,101 +0,0 @@
import type { NumberLike, StringLike } from "@visx/scale";
import { formatValue } from "metabase/lib/formatting";
import { getFormattingOptionsWithoutScaling } from "metabase/visualizations/echarts/cartesian/model/util";
import type { CartesianChartColumns } from "metabase/visualizations/lib/graph/columns";
import { getStackOffset } from "metabase/visualizations/lib/settings/stacking";
import type {
ChartTicksFormatters,
ValueFormatter,
} from "metabase/visualizations/shared/types/format";
import { getLabelsMetricColumn } from "metabase/visualizations/shared/utils/series";
import { getColumnSettings } from "metabase-lib/v1/queries/utils/column-key";
import type {
DatasetColumn,
RowValue,
VisualizationSettings,
} from "metabase-types/api";
export const getStaticFormatters = (
chartColumns: CartesianChartColumns,
settings: VisualizationSettings,
): ChartTicksFormatters => {
const yTickFormatter = (value: StringLike) => {
const column = chartColumns.dimension.column;
const columnSettings = getColumnSettings(settings, column);
const options = getFormattingOptionsWithoutScaling({
column,
...columnSettings,
jsx: false,
});
return String(formatValue(value, options));
};
const metricColumn = getLabelsMetricColumn(chartColumns);
const percentXTicksFormatter = (percent: NumberLike) => {
const column = metricColumn.column;
const number_separators = getColumnSettings(
settings,
column,
)?.number_separators;
const options = getFormattingOptionsWithoutScaling({
column,
number_separators,
jsx: false,
number_style: "percent",
decimals: 2,
});
return String(formatValue(percent, options));
};
const xTickFormatter = (value: NumberLike) => {
const column = metricColumn.column;
const columnSettings = getColumnSettings(settings, column);
const options = getFormattingOptionsWithoutScaling({
column,
...columnSettings,
jsx: false,
});
return String(formatValue(value, options));
};
const shouldFormatXTicksAsPercent = getStackOffset(settings) === "expand";
return {
yTickFormatter,
xTickFormatter: shouldFormatXTicksAsPercent
? percentXTicksFormatter
: xTickFormatter,
};
};
export const getLabelsStaticFormatter = (
chartColumns: CartesianChartColumns,
settings: VisualizationSettings,
): ValueFormatter => {
const column = getLabelsMetricColumn(chartColumns).column;
const columnSettings = getColumnSettings(settings, column);
const options = getFormattingOptionsWithoutScaling({
column,
...columnSettings,
jsx: false,
compact: settings["graph.label_value_formatting"] === "compact",
});
const labelsFormatter = (value: any) => String(formatValue(value, options));
return labelsFormatter;
};
export const getColumnValueStaticFormatter = () => {
return (value: RowValue, column: DatasetColumn) => {
return String(formatValue(value, { column }));
};
};

View File

@@ -156,6 +156,18 @@ interface SavePdfProps {
includeBranding: boolean;
}
async function isValidColor(str: string) {
const { default: jspdf } = await import("jspdf");
const pdf = new jspdf();
try {
pdf.setFillColor(str);
return true;
} catch {
console.warn(`Unsupported color string: "${str}"`);
return false;
}
}
export const saveDashboardPdf = async ({
selector,
dashboardName,
@@ -204,10 +216,14 @@ export const saveDashboardPdf = async ({
headerHeight + parametersHeight + (includeBranding ? brandingHeight : 0);
const contentHeight = gridNode.offsetHeight + verticalOffset;
const backgroundColor = getComputedStyle(document.documentElement)
let backgroundColor = getComputedStyle(document.documentElement)
.getPropertyValue("--mb-color-bg-dashboard")
.trim();
if (!(await isValidColor(backgroundColor))) {
backgroundColor = "white"; // Fallback to white if the color is invalid
}
const { default: html2canvas } = await import("html2canvas-pro");
const image = await html2canvas(gridNode, {
height: contentHeight,

View File

@@ -0,0 +1,13 @@
import fetchMock from "fetch-mock";
import type { Document } from "metabase-types/api";
export function setupDocumentEndpoints(doc: Document) {
fetchMock.get(`path:/api/ee/document/${doc.id}`, doc);
fetchMock.post(`path:/api/ee/document/${doc.id}`, {});
fetchMock.put(`path:/api/ee/document/${doc.id}`, async (call) => {
const body = await call?.request?.json();
return { ...doc, ...body };
});
fetchMock.delete(`path:/api/ee/document/${doc.id}`, {});
}

View File

@@ -14,6 +14,7 @@ export * from "./constants";
export * from "./dashboard";
export * from "./database";
export * from "./dataset";
export * from "./document";
export * from "./email";
export * from "./embed";
export * from "./field";

View File

@@ -1799,7 +1799,7 @@
:collection_name :name
:collection_type :type
:location true}
:where [:= :namespace nil]
:where [:or [:= :namespace nil] [:= :namespace "analytics"]]
;; depends on the current user, used for rendering and ranking
;; TODO not sure this is what it'll look like
:bookmark [:model/CollectionBookmark [:and

View File

@@ -339,7 +339,7 @@
(value :guard number?)
value
[:value (_ :guard #(= (:base-type %) :type/BigInteger)) (value :guard string?)]
[:value (x :guard (= (:base-type x) :type/BigInteger)) (value :guard string?)]
(u.number/parse-bigint value)))
(def ^:private NumberFilterParts
@@ -557,7 +557,7 @@
[:time-interval
opts
(col-ref :guard date-col?)
(value :guard #(or (number? %) (= :current %)))
(value :guard (or (number? value) (= :current value)))
(unit :guard keyword?)]
{:column (ref->col col-ref)
:value (if (= value :current) 0 value)

View File

@@ -1,7 +1,7 @@
(ns metabase.lib.field.resolution
"Code for resolving field metadata from a field ref. There's a lot of code here, isn't there? This is probably more
complicated than it needs to be!"
(:refer-clojure :exclude [some select-keys])
(:refer-clojure :exclude [not-empty some select-keys])
(:require
#?@(:clj
([metabase.config.core :as config]))
@@ -26,19 +26,20 @@
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[metabase.util.performance :refer [some select-keys]]))
[metabase.util.performance :refer [not-empty some select-keys]]))
(mr/def ::id-or-name
[:or :string ::lib.schema.id/field])
(defn- merge-metadata
[m & more]
(defn- merge-metadata [maps]
(not-empty
(into (or m {})
(comp cat
(filter (fn [[_k v]]
(some? v))))
more)))
;; Intentionally using 2-arity clojure.core/reduce here because we want the behavior of taking the first map from
;; the list as the accumulator and adding values from subsequent maps onto it.
#_{:clj-kondo/ignore [:reduce-without-init]}
(reduce #(reduce-kv (fn [acc k v]
(cond-> acc (some? v) (assoc k v)))
%1 %2)
maps)))
(mu/defn- add-parent-column-metadata
"If this is a nested column, add metadata about the parent column."
@@ -625,10 +626,8 @@
(maybe-resolve-expression-in-current-stage query stage-number id-or-name)
;; if we STILL can't find a match, return made-up fallback metadata.
(fallback-metadata id-or-name))]
(merge-metadata
col
(when col
(additional-metadata-from-source-card query stage-number col)))))
(when col
(merge-metadata [col (additional-metadata-from-source-card query stage-number col)]))))
(mu/defn resolve-field-ref :- ::lib.metadata.calculation/visible-column
"Resolve metadata for a `:field` ref. This is part of the implementation
@@ -641,26 +640,26 @@
(let [stage-number (lib.util/canonical-stage-index query stage-number)]
(log/debugf "Resolving %s in stage %s" (pr-str id-or-name) (pr-str stage-number))
(-> (merge-metadata
{:lib/type :metadata/column}
(or (when join-alias
(resolve-in-join query stage-number join-alias source-field id-or-name))
(when source-field
(resolve-in-implicit-join query stage-number source-field id-or-name))
(resolve-from-previous-stage-or-source query stage-number id-or-name)
(merge
(or (fallback-metadata-for-field query stage-number id-or-name)
(fallback-metadata id-or-name))
(when (and join-alias
(contains? (into #{}
(map :alias)
(:joins (lib.util/query-stage query stage-number)))
join-alias))
{:lib/source :source/joins
:metabase.lib.join/join-alias join-alias})))
(options-metadata opts)
{:lib/original-ref-style-for-result-metadata-purposes (if (pos-int? id-or-name)
:original-ref-style/id
:original-ref-style/name)})
[{:lib/type :metadata/column}
(or (when join-alias
(resolve-in-join query stage-number join-alias source-field id-or-name))
(when source-field
(resolve-in-implicit-join query stage-number source-field id-or-name))
(resolve-from-previous-stage-or-source query stage-number id-or-name)
(merge
(or (fallback-metadata-for-field query stage-number id-or-name)
(fallback-metadata id-or-name))
(when (and join-alias
(contains? (into #{}
(map :alias)
(:joins (lib.util/query-stage query stage-number)))
join-alias))
{:lib/source :source/joins
:metabase.lib.join/join-alias join-alias})))
(options-metadata opts)
{:lib/original-ref-style-for-result-metadata-purposes (if (pos-int? id-or-name)
:original-ref-style/id
:original-ref-style/name)}])
(as-> $col (assoc $col :display-name (lib.metadata.calculation/display-name query stage-number $col)))
;; `:lib/desired-column-alias` needs to be recalculated in the context of the stage where the ref
;; appears, go ahead and remove it so we don't accidentally try to use it when it may or may not be

View File

@@ -120,7 +120,7 @@
[(_ :guard #{:!= :not-in}) _ [:get-year _ (a :guard temporal?)] (b :guard int?)]
(i18n/tru "{0} excludes {1}" (->unbucketed-display-name a) (u.time/format-unit b :year))
[(op :guard #{:= :in :!= :not-in}) _ [(f :guard #{:get-hour :get-month :get-quarter :get-year}) _ (a :guard temporal?)] & (args :guard #(every? int? %))]
[(op :guard #{:= :in :!= :not-in}) _ [(f :guard #{:get-hour :get-month :get-quarter :get-year}) _ (a :guard temporal?)] & (args :guard (every? int? args))]
(i18n/tru "{0} {1} {2} {3} selections"
(->unbucketed-display-name a)
(if (#{:= :in} op) "is one of" "excludes")

View File

@@ -182,8 +182,8 @@
(keep (fn [clause]
(lib.util.match/match-lite-recursive clause
[(op :guard (= op target-op))
(_ :guard #(or (empty? target-opts)
(set/subset? (set target-opts) (set %))))
(opts :guard (or (empty? target-opts)
(set/subset? (set target-opts) (set opts))))
(id :guard (= id target-ref-id))] [location clause]))))))
(stage-paths query stage-number))
dead-joins (volatile! (transient []))]
@@ -196,7 +196,7 @@
(catch #?(:clj Exception :cljs js/Error) e
(let [{:keys [error join]} (ex-data e)]
(if (= error :metabase.lib.util/cannot-remove-final-join-condition)
;; Return the stage unchanged, but keep track of the dead joins.
;; Return the stage unchanged, but keep track of the dead joins.
(do (vswap! dead-joins conj! join)
%1)
(throw e)))))))

View File

@@ -277,9 +277,19 @@
(vswap! bindings conj [s `(metabase.lib.util.match.impl/map! ~value)])
(run! (fn [[k v]] (process-pattern v (list `get s k) bindings conditions false)) (:map parsed)))
:guard (let [s (:symbol parsed)
bind (when-not (= s '_) s)]
bind (when-not (= s '_) s)
;; Treat symbol, keyword, or set predicates as functions to be called, and thus transform them
;; into invocation snippets. Be careful that if user doesn't want to bind the value in the
;; guard (signified by `_`), we should pass the directly extracted value to the predicate,
;; otherwise the binding.
predicate (if (or (symbol? predicate) (keyword? predicate) (set? predicate))
(list predicate (or bind value))
predicate)]
(when bind (vswap! bindings conj [bind value]))
(when (:predicate parsed)
(when predicate
;; Make sure that the predicate is an invocation snippet, not a lambda as in regular `match` syntax.
(when (and (seq? predicate) ('#{fn fn*} (first predicate)))
(throw (ex-info "match-lite :guard predicate must be an invocation form or a symbol, not a lambda" {:predicate predicate})))
(vswap! conditions conj (with-meta (if (and (seq? predicate) (not= (first predicate) 'fn*))
predicate
(list predicate (or bind value)))
@@ -351,7 +361,9 @@
(process-pattern pattern value-sym all-vectors?))
{:keys [common-bindings common-conditions all-bindings all-conditions]}
(collect-common processed-patterns)
same-result? (apply = (map second pairs))
same-result? (and (apply = (map second pairs))
;; Only allow extracting same result if there are no individual bindings in branches.
(every? empty? all-bindings))
value-binding (if (or (= value-sym value) recursive?)
[] [value-sym value])
body `(let [~@value-binding
@@ -396,7 +408,7 @@
- symbol - binds the entire value
- keyword - must match exactly
- set - must be one of the set items
- (sym :guard pred :len size) - bind with predicate check. Can optionally check for collection length.
- (sym :guard pred :len size) - bind with predicate check. The predicate should either be a symbol denoting a function, keyword, set, or an invocation snippet (but not a lambda). Can optionally check for collection length.
- vector - binds positional values inside a sequence against other patterns. Can have & to bind remaining elements."
{:style/indent :defn}
[value & clauses]

View File

@@ -42,7 +42,7 @@
[_query _path-type _path clause]
(lib.util.match/match-lite clause
[:value
(opts :guard (fn [{:keys [effective-type], :as _value-options}]
(opts :guard (let [{:keys [effective-type]} opts]
(and effective-type
(not (isa? effective-type :type/Text)))))
(v :guard string?)]

View File

@@ -11,6 +11,7 @@
[metabase.lib.schema.common :as lib.schema.common]
[metabase.models.humanization :as humanization]
[metabase.models.interface :as mi]
[metabase.premium-features.core :refer [defenterprise]]
[metabase.sync.fetch-metadata :as fetch-metadata]
[metabase.sync.interface :as i]
[metabase.sync.sync-metadata.crufty :as crufty]
@@ -81,6 +82,12 @@
;; MSSQL
#"^syncobj_0x.*"})
(defenterprise is-temp-transform-table?
"Return true if `table` references a temporary transform table created during transforms execution."
metabase-enterprise.transforms.util
[_table]
false)
;;; ---------------------------------------------------- Syncing -----------------------------------------------------
(mu/defn- update-database-metadata!
@@ -243,7 +250,9 @@
Get set of user tables only, excluding metabase metadata tables."
[db-metadata :- i/DatabaseMetadata]
(into #{}
(remove metabase-metadata/is-metabase-metadata-table?)
(remove (fn [table]
(or (metabase-metadata/is-metabase-metadata-table? table)
(is-temp-transform-table? table))))
(:tables db-metadata)))
(mu/defn- select-tables :- [:set (ms/InstanceOf :model/Table)]

View File

@@ -750,7 +750,7 @@
2 :magenta
3 :yellow) "%s%s took %s"
(if (pos? *profile-level*)
(str (str/join (repeat (dec *profile-level*) " ")) " ⮦ ")
(str "┌" (str/join (repeat (dec *profile-level*) "─")) "─> ")
"")
(message-thunk)
(u.format/format-nanoseconds (- #?(:cljs (* 1000000 (js/performance.now))

View File

@@ -1,13 +1,13 @@
(ns metabase.util.performance
"Functions and utilities for faster processing. This namespace is compatible with both Clojure and ClojureScript.
However, some functions are either not only available in CLJS, or offer passthrough non-improved functions."
(:refer-clojure :exclude [reduce mapv run! some every? concat select-keys update-keys #?(:cljs clj->js)])
(:refer-clojure :exclude [reduce mapv run! some every? concat select-keys update-keys empty? not-empty #?(:cljs clj->js)])
#?@(:clj ()
:cljs [(:require
[cljs.core :as core]
[goog.object :as gobject])])
#?@(:clj [(:import (clojure.lang ITransientCollection LazilyPersistentVector RT)
(java.util ArrayList HashMap Iterator))]
#?@(:clj [(:import (clojure.lang Counted ITransientCollection LazilyPersistentVector RT)
(java.util ArrayList HashMap Iterator List))]
:default ()))
#?(:clj (set! *warn-on-reflection* true))
@@ -44,6 +44,22 @@
;; Collection functions
(defn empty?
"Returns true if coll has no items. Tries to avoid using `seq` for better performance."
[coll]
#?(:clj
(cond (instance? List coll) (.isEmpty ^List coll)
(instance? Counted coll) (= (.count ^Counted coll) 0)
:else (not (seq coll)))
:cljs (not (seq coll))))
(defn not-empty
"If coll is empty, returns nil, else coll. Tries to avoid using `seq` for better performance."
[coll]
#?(:clj
(when-not (empty? coll) coll)
:cljs (clojure.core/not-empty coll)))
#?(:clj
(defn reduce
"Like `clojure.core/reduce`, but uses iterators under the hood to walk the collections and can iterate several

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