Compare commits
106 Commits
dark-mode
...
python-run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c72149945 | ||
|
|
350e69e4e9 | ||
|
|
ad9e3af2cd | ||
|
|
9bc2f603e2 | ||
|
|
b9e1f5774d | ||
|
|
0e93fb8dbc | ||
|
|
92e9305d20 | ||
|
|
df4a0869b5 | ||
|
|
581c1e7a38 | ||
|
|
17a61a81c8 | ||
|
|
1d699a5fae | ||
|
|
277d1fdaff | ||
|
|
82b3ba1fcc | ||
|
|
dd7845cc83 | ||
|
|
7e7ed1ac9f | ||
|
|
19387d8d29 | ||
|
|
7ac8feb49b | ||
|
|
4447e2c6bc | ||
|
|
8434b23764 | ||
|
|
be72407fc8 | ||
|
|
e530911305 | ||
|
|
ca9ba3390d | ||
|
|
a947f3cb94 | ||
|
|
33de954fcf | ||
|
|
db79c64d3d | ||
|
|
3e646a69f3 | ||
|
|
d6c15aab01 | ||
|
|
34c224b5f2 | ||
|
|
568443a99c | ||
|
|
5fe1efe15b | ||
|
|
894176cf26 | ||
|
|
c28f66c961 | ||
|
|
85e6e7f779 | ||
|
|
d44a2cab66 | ||
|
|
e349faa741 | ||
|
|
817af7b71d | ||
|
|
e8cd58b376 | ||
|
|
62d7beec2b | ||
|
|
c2ec56beed | ||
|
|
5274867997 | ||
|
|
b5a9d7457f | ||
|
|
12f4387864 | ||
|
|
6b5e3bd646 | ||
|
|
b7c4063dd9 | ||
|
|
49fcc4a40f | ||
|
|
7fb62b782b | ||
|
|
f393237eb9 | ||
|
|
48376d3f63 | ||
|
|
a67c48a036 | ||
|
|
8c3d77473a | ||
|
|
9f029dbb2f | ||
|
|
1adaaae66e | ||
|
|
21956d6ab7 | ||
|
|
fd94a0b0cb | ||
|
|
7b6fbd6374 | ||
|
|
eeecd9a10a | ||
|
|
685424df74 | ||
|
|
c4af9c415b | ||
|
|
ca66414190 | ||
|
|
855e846d0a | ||
|
|
270086315f | ||
|
|
16ea0dc141 | ||
|
|
94b9737f0b | ||
|
|
4448933543 | ||
|
|
a9e0afd420 | ||
|
|
d018b4a1bc | ||
|
|
92011484de | ||
|
|
c10fd5cd5b | ||
|
|
564556d706 | ||
|
|
5e55ecd75c | ||
|
|
0a33f82baf | ||
|
|
7ecdc2f757 | ||
|
|
0bf1ac867f | ||
|
|
cbdc23a7bf | ||
|
|
c59e9de090 | ||
|
|
9668ebe98c | ||
|
|
3c2d46151b | ||
|
|
1cacd1f510 | ||
|
|
a8074ca04b | ||
|
|
28014961c3 | ||
|
|
ec5b20af0c | ||
|
|
ab68e21cf7 | ||
|
|
821c0f624b | ||
|
|
7423aaf572 | ||
|
|
03f68f0a2c | ||
|
|
e8f125c19b | ||
|
|
21c5271879 | ||
|
|
45d701fd6b | ||
|
|
7c1152e9d0 | ||
|
|
ab20852102 | ||
|
|
137bc61a42 | ||
|
|
f567aeece1 | ||
|
|
e60e38682b | ||
|
|
84efc79be4 | ||
|
|
8de8d13eb6 | ||
|
|
a52d58a3e2 | ||
|
|
f34cb3d5c3 | ||
|
|
d8571ccabe | ||
|
|
19f279610b | ||
|
|
5647233576 | ||
|
|
14481bcf2f | ||
|
|
bbccbb4842 | ||
|
|
4b99060f46 | ||
|
|
3903af487f | ||
|
|
0fb061b839 | ||
|
|
ab9e7bbcb8 |
@@ -173,6 +173,7 @@
|
||||
metabase.driver.sql-jdbc.execute/execute-prepared-statement!
|
||||
metabase.notification.models/create-notification!
|
||||
metabase.pulse.send/send-pulse!
|
||||
metabase-enterprise.transforms-python.python-runner-test/execute!
|
||||
;; Safe in tests, where we typically use a private, temporary index per thread.
|
||||
metabase.search.core/reindex!
|
||||
metabase.util/run-count!
|
||||
@@ -418,6 +419,7 @@
|
||||
metabase-enterprise.transforms.api-test/with-transform-tags clojure.core/let
|
||||
metabase-enterprise.transforms.api-test/with-worker-runs clojure.core/let
|
||||
metabase-enterprise.transforms.test-util/with-transform-cleanup! clojure.core/let
|
||||
metabase-enterprise.transforms.execute/with-transform-lifecycle clojure.core/let
|
||||
metabase.actions.test-util/with-actions clojure.core/let
|
||||
metabase.api.common/let-404 clojure.core/let
|
||||
metabase.app-db.custom-migrations.util/with-temp-scheduler! clojure.core/fn
|
||||
@@ -519,6 +521,7 @@
|
||||
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
|
||||
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
|
||||
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
|
||||
metabase-enterprise.transforms-python.drivers-test/with-test-table hooks.common/with-two-bindings
|
||||
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding
|
||||
metabase.api.macros/defendpoint hooks.metabase.api.macros/defendpoint
|
||||
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations
|
||||
@@ -832,7 +835,16 @@
|
||||
performance-sensitive
|
||||
{:linters
|
||||
{:discouraged-var
|
||||
{clojure.core/update-keys {:message "Use metabase.util.performance/update-keys"}}}}
|
||||
{clojure.core/every? {:message "Use metabase.util.performance/every?"}
|
||||
clojure.core/mapv {:message "Use metabase.util.performance/mapv"}
|
||||
clojure.core/run! {:message "Use metabase.util.performance/run!"}
|
||||
clojure.core/select-keys {:message "Use metabase.util.performance/select-keys"}
|
||||
clojure.core/some {:message "Use metabase.util.performance/some"}
|
||||
clojure.core/update-keys {:message "Use metabase.util.performance/update-keys"}
|
||||
clojure.walk/keywordize-keys {:message "Use metabase.util.performance/keywordize-keys"}
|
||||
clojure.walk/postwalk {:message "Use metabase.util.performance/postwalk"}
|
||||
clojure.walk/prewalk {:message "Use metabase.util.performance/prewalk"}
|
||||
clojure.walk/walk {:message "Use metabase.util.performance/walk"}}}}
|
||||
|
||||
qp-and-driver-source-namespaces
|
||||
{:linters
|
||||
|
||||
@@ -502,6 +502,7 @@
|
||||
metabase.embedding.init
|
||||
metabase.embedding.jwt
|
||||
metabase.embedding.settings
|
||||
metabase.embedding.util
|
||||
metabase.embedding.validation}
|
||||
:uses #{analytics
|
||||
api
|
||||
@@ -893,6 +894,19 @@
|
||||
enterprise/advanced-permissions
|
||||
enterprise/sandbox}}
|
||||
|
||||
python-runner
|
||||
{:team "Workflows"
|
||||
:api #{metabase.python-runner.api
|
||||
metabase.python-runner.core
|
||||
metabase.python-runner.init}
|
||||
:uses #{api
|
||||
driver
|
||||
models
|
||||
query-processor
|
||||
request
|
||||
util
|
||||
enterprise/transforms}}
|
||||
|
||||
queries
|
||||
{:team "Querying"
|
||||
:api #{metabase.queries.api
|
||||
@@ -938,6 +952,7 @@
|
||||
:uses #{api
|
||||
legacy-mbql
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
permissions
|
||||
query-processor
|
||||
@@ -1020,6 +1035,7 @@
|
||||
metabase.request.schema}
|
||||
:uses #{api
|
||||
config
|
||||
embedding
|
||||
permissions ; FIXME
|
||||
session
|
||||
settings
|
||||
@@ -1225,6 +1241,7 @@
|
||||
events
|
||||
lib
|
||||
models
|
||||
premium-features
|
||||
query-processor
|
||||
settings
|
||||
task
|
||||
@@ -1470,6 +1487,7 @@
|
||||
driver
|
||||
legacy-mbql
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
queries
|
||||
query-permissions
|
||||
@@ -1813,10 +1831,12 @@
|
||||
pulse
|
||||
query-processor
|
||||
request
|
||||
search
|
||||
server
|
||||
settings
|
||||
sync
|
||||
system
|
||||
task
|
||||
users
|
||||
util
|
||||
warehouse-schema
|
||||
@@ -1978,6 +1998,7 @@
|
||||
:api #{metabase-enterprise.transforms.api
|
||||
metabase-enterprise.transforms.core
|
||||
metabase-enterprise.transforms.init
|
||||
metabase-enterprise.transforms.instrumentation
|
||||
metabase-enterprise.transforms.util}
|
||||
:uses #{analytics
|
||||
api
|
||||
@@ -2003,6 +2024,7 @@
|
||||
enterprise/transforms-python
|
||||
{:team "Querying"
|
||||
:api #{metabase-enterprise.transforms-python.api
|
||||
metabase-enterprise.transforms-python.execute
|
||||
metabase-enterprise.transforms-python.init
|
||||
metabase-enterprise.transforms-python.settings}
|
||||
:uses #{api
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -3,7 +3,7 @@
|
||||
|
||||
> **Warning**
|
||||
>
|
||||
> If that is your first contribution to Metabase, please sign the [Contributor License Agreement](https://docs.google.com/a/metabase.com/forms/d/1oV38o7b9ONFSwuzwmERRMi9SYrhYeOrkbmNaq9pOJ_E/viewform) (unless it's a tiny documentation change). Also, if you're attempting to fix a translation issue, please submit your changes to our [Crowdin project](https://crowdin.com/project/metabase-i18n) instead of opening a PR.
|
||||
> If that is your first contribution to Metabase, please sign the [Contributor License Agreement](https://docs.google.com/a/metabase.com/forms/d/1oV38o7b9ONFSwuzwmERRMi9SYrhYeOrkbmNaq9pOJ_E/viewform). Also, if you're attempting to fix a translation issue, please submit your changes to our [Crowdin project](https://crowdin.com/project/metabase-i18n) instead of opening a PR.
|
||||
|
||||
Closes https://github.com/metabase/metabase/issues/[issue_number]
|
||||
|
||||
|
||||
30
.github/scripts/setup-python-runner.sh
vendored
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
SKIP_START=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--skip-start)
|
||||
SKIP_START=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [--skip-start]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Cloning python-runner repository..."
|
||||
rm -r python-runner
|
||||
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
|
||||
if [ "$SKIP_START" = false ]; then
|
||||
echo "Starting python-runner..."
|
||||
cd python-runner
|
||||
make run-ci
|
||||
cd ..
|
||||
fi
|
||||
20
.github/workflows/app-db.yml
vendored
@@ -35,21 +35,21 @@ jobs:
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '"enterprise/backend/test"'
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 1)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 2)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 1
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
@@ -147,21 +147,21 @@ jobs:
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '"enterprise/backend/test"'
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 1)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 2)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 1
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
@@ -259,11 +259,13 @@ jobs:
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
exclude-tag: ':mb/transforms-python-test'
|
||||
- name: Enterprise Tests
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '"enterprise/backend/test"'
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 1)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
@@ -271,14 +273,14 @@ jobs:
|
||||
:exclude-tags [:mb/driver-tests]
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
- name: EE App DB Tests (Part 2)
|
||||
build-static-viz: true
|
||||
test-args: >-
|
||||
:only '"test"'
|
||||
:partition/total 2
|
||||
:partition/index 1
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
env:
|
||||
# Only run old migrations tests on pushes to master or release branches. All other branches should skip tests
|
||||
|
||||
157
.github/workflows/drivers.yml
vendored
@@ -90,6 +90,7 @@ jobs:
|
||||
junit-name: 'be-tests-athena-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -106,6 +107,19 @@ jobs:
|
||||
if: ${{ !inputs.skip }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- name: Driver Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
# 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
|
||||
@@ -116,15 +130,19 @@ jobs:
|
||||
MB_BIGQUERY_TEST_REFRESH_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_REFRESH_TOKEN }}
|
||||
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON }}
|
||||
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING }}
|
||||
name: BigQuery
|
||||
name: BigQuery ${{ matrix.job.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup python-runner
|
||||
if: ${{ matrix.job.needs-python-runner == true }}
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: ./.github/scripts/setup-python-runner.sh
|
||||
- name: Test BigQuery Cloud SDK driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-bigquery-cloud-sdk-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -141,6 +159,18 @@ jobs:
|
||||
if: ${{ !inputs.skip }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- name: Driver Tests
|
||||
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
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: clickhouse
|
||||
@@ -148,9 +178,14 @@ jobs:
|
||||
MB_CLICKHOUSE_TEST_PORT: 8123
|
||||
MB_CLICKHOUSE_TEST_OLD_PORT: 8124
|
||||
MB_CLICKHOUSE_TEST_NGINX_PORT: 8127
|
||||
name: Clickhouse
|
||||
name: Clickhouse ${{ matrix.job.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup python-runner
|
||||
if: ${{ matrix.job.needs-python-runner == true }}
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: ./.github/scripts/setup-python-runner.sh
|
||||
- name: Add ClickHouse TLS instance to /etc/hosts
|
||||
run: |
|
||||
sudo echo "127.0.0.1 server.clickhouseconnect.test" | sudo tee -a /etc/hosts
|
||||
@@ -170,8 +205,7 @@ jobs:
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-clickhouse-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -208,6 +242,7 @@ jobs:
|
||||
junit-name: 'be-tests-druid-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -244,6 +279,7 @@ jobs:
|
||||
junit-name: 'be-tests-druid-jdbc-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -280,6 +316,7 @@ jobs:
|
||||
junit-name: 'be-tests-databricks-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -317,6 +354,7 @@ jobs:
|
||||
junit-name: 'be-tests-databricks-multi-catalog-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -338,12 +376,17 @@ jobs:
|
||||
name: H2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup python-runner
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: ./.github/scripts/setup-python-runner.sh
|
||||
- name: Test H2 driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-h2'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -388,6 +431,11 @@ jobs:
|
||||
- name: Driver Tests
|
||||
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
|
||||
|
||||
services:
|
||||
mysql:
|
||||
@@ -442,6 +490,11 @@ jobs:
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup python-runner
|
||||
#if: ${{ matrix.job.needs-python-runner == true }}
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: ./.github/scripts/setup-python-runner.sh
|
||||
- name: Test ${{ matrix.version.name }}
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
@@ -476,6 +529,15 @@ jobs:
|
||||
- name: MongoDB Latest
|
||||
junit-name: be-tests-mongo-latest-ee
|
||||
image: mongo:latest
|
||||
job:
|
||||
- name: Driver Tests
|
||||
test-args: >-
|
||||
:exclude-tags '[:mongo-sharded-cluster-tests :mb/transforms-python-test]'
|
||||
:only-tags [:mb/driver-tests]
|
||||
- name: Transforms Python Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
needs-python-runner: true
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: mongo
|
||||
@@ -489,16 +551,19 @@ jobs:
|
||||
env:
|
||||
MONGO_INITDB_ROOT_USERNAME: metabase
|
||||
MONGO_INITDB_ROOT_PASSWORD: metasample123
|
||||
name: ${{ matrix.version.name }}
|
||||
name: ${{ matrix.version.name }} ${{ matrix.job.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup python-runner
|
||||
if: ${{ matrix.job.needs-python-runner == true }}
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: ./.github/scripts/setup-python-runner.sh
|
||||
- name: Test ${{ matrix.version.name }}
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
test-args: >-
|
||||
:exclude-tags [:mongo-sharded-cluster-tests]
|
||||
:only-tags [:mb/driver-tests]
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -562,7 +627,7 @@ jobs:
|
||||
with:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
test-args: >-
|
||||
:exclude-tags [:mongo-sharded-cluster-tests]
|
||||
:exclude-tags '[:mongo-sharded-cluster-tests :mb/transforms-python-test]'
|
||||
:only-tags [:mb/driver-tests]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
@@ -670,6 +735,7 @@ jobs:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -704,6 +770,11 @@ jobs:
|
||||
- name: Driver Tests
|
||||
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
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
env:
|
||||
# Only run old migrations tests on pushes to master or release branches. All other branches should skip tests
|
||||
@@ -745,6 +816,11 @@ jobs:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup python-runner
|
||||
#if: ${{ matrix.job.needs-python-runner == true }}
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: ./.github/scripts/setup-python-runner.sh
|
||||
- name: Test ${{ matrix.version.name }} (${{ matrix.job.name }})
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
@@ -821,6 +897,7 @@ jobs:
|
||||
junit-name: 'be-tests-presto-jdbc-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -850,8 +927,14 @@ jobs:
|
||||
skip: false
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/upload-tests]
|
||||
:exclude-tags '[:mb/upload-tests :mb/transforms-python-test]'
|
||||
:fail-fast? true
|
||||
- name: Redshift Transforms Python Tests
|
||||
skip: false
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
needs-python-runner: true
|
||||
|
||||
env:
|
||||
CI: 'true'
|
||||
@@ -864,6 +947,11 @@ jobs:
|
||||
name: ${{ matrix.job.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup python-runner
|
||||
if: ${{ matrix.job.needs-python-runner == true }}
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: ./.github/scripts/setup-python-runner.sh
|
||||
- name: Test ${{ matrix.job.name }}
|
||||
if: ${{ !matrix.job.skip }}
|
||||
uses: ./.github/actions/test-driver
|
||||
@@ -887,6 +975,19 @@ jobs:
|
||||
if: ${{ !inputs.skip }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- name: Driver Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
# 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
|
||||
@@ -905,15 +1006,19 @@ jobs:
|
||||
MB_SNOWFLAKE_TEST_RSA_ROLE_TEST_CUSTOM_USER: RSA_ROLE_TEST_CUSTOM_USER
|
||||
MB_SNOWFLAKE_TEST_RSA_ROLE_TEST_ROLE: RSA_ROLE_TEST_ROLE
|
||||
MB_SNOWFLAKE_TEST_RSA_ROLE_TEST_DB: RSA_ROLE_TEST_DB
|
||||
name: Snowflake
|
||||
name: Snowflake ${{ matrix.job.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup python-runner
|
||||
if: ${{ matrix.job.needs-python-runner == true }}
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: ./.github/scripts/setup-python-runner.sh
|
||||
- name: Test Snowflake driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-snowflake-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -948,6 +1053,7 @@ jobs:
|
||||
junit-name: 'be-tests-sparksql-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -977,6 +1083,7 @@ jobs:
|
||||
junit-name: 'be-tests-sqlite-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -1004,6 +1111,15 @@ jobs:
|
||||
- name: SQL Server 2022
|
||||
junit-name: be-tests-sqlserver-2022-ee
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
job:
|
||||
- name: Driver Tests
|
||||
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
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: sqlserver
|
||||
@@ -1019,15 +1135,19 @@ jobs:
|
||||
ACCEPT_EULA: Y
|
||||
SA_PASSWORD: 'P@ssw0rd'
|
||||
MSSQL_MEMORY_LIMIT_MB: 1024
|
||||
name: ${{ matrix.version.name }}
|
||||
name: ${{ matrix.version.name }} ${{ matrix.job.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup python-runner
|
||||
if: ${{ matrix.job.needs-python-runner == true }}
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: ./.github/scripts/setup-python-runner.sh
|
||||
- name: Test ${{ matrix.version.name }}
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
@@ -1136,6 +1256,7 @@ jobs:
|
||||
junit-name: 'be-tests-vertica-ee'
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
|
||||
8
.github/workflows/e2e-test.yml
vendored
@@ -60,6 +60,14 @@ jobs:
|
||||
CYPRESS_IS_EMBEDDING_SDK: "${{ inputs.name == 'embedding-sdk' }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
|
||||
- name: Setup python-runner
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: ./.github/scripts/setup-python-runner.sh
|
||||
|
||||
- name: Prepare Docker containers
|
||||
uses: ./.github/actions/e2e-prepare-containers
|
||||
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 58 KiB |
2
deps.edn
@@ -198,6 +198,8 @@
|
||||
org.eclipse.jetty.ee9/jetty-ee9-servlet
|
||||
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server]}
|
||||
slingshot/slingshot {:mvn/version "0.12.2"} ; enhanced throw/catch, used by other deps
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.33.0"} ; AWS SDK v2 for S3
|
||||
software.amazon.awssdk/sts {:mvn/version "2.33.0"} ; Required for S3 client to use service account
|
||||
stencil/stencil {:mvn/version "0.5.0"} ; Mustache templates for Clojure
|
||||
user-agent/user-agent {:mvn/version "0.1.1"} ; User-Agent string parser, for Login History page & elsewhere
|
||||
weavejester/dependency {:mvn/version "1.0.0"} ; Dependency graphs and topological sorting
|
||||
|
||||
@@ -61,7 +61,7 @@ Once one or more people have started to work on a feature, it should be marked [
|
||||
|
||||
### E) Verification and merging
|
||||
|
||||
All PRs that involve more than an insignificant change should be reviewed. See our [Code Review Process](./developers-guide/code-reviews.md).
|
||||
All PRs that involve more than an insignificant change should be reviewed. See our [Code Review Process](https://www.metabase.com/docs/master/developers-guide/code-reviews).
|
||||
|
||||
If all goes well, the feature gets coded up, verified and then the pull request gets merged! High-fives all around.
|
||||
|
||||
@@ -95,7 +95,7 @@ By our definition, "Bugs" are situations where the program doesn't do what it wa
|
||||
|
||||
There are a lot of docs, which means keeping them up to date is hard. If you notice inconsistencies, errors, or outdated information, please help us keep them current!
|
||||
|
||||
Note that **we cannot accept translations for documentation at this time**. We support [in-app translations](./configuring-metabase/localization.md), and only support languages that have 100% coverage. But 1) the in-app text is orders of magnitude shorter than our docs, 2) it changes at a slower pace, and 3) we have a lot of people help out. We may consider supporting docs in multiple languages in the future, but for now we need to focus our resources on improving our existing documentation (and expanding it to include all of the new features we're adding).
|
||||
Note that **we cannot accept translations for documentation at this time**. We support [in-app translations](https://www.metabase.com/docs/master/configuring-metabase/localization), and only support languages that have 100% coverage. But 1) the in-app text is orders of magnitude shorter than our docs, 2) it changes at a slower pace, and 3) we have a lot of people help out. We may consider supporting docs in multiple languages in the future, but for now we need to focus our resources on improving our existing documentation (and expanding it to include all of the new features we're adding).
|
||||
|
||||
### Working on features
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 129 KiB |
@@ -83,7 +83,7 @@ To have Metabot generate SQL for you:
|
||||
3. Type cmd+b on Mac, ctrl+b on Windows, to open up the [chat sidebar](#the-metabot-chat-sidebar).
|
||||
4. Ask it to "Write a SQL query that..." and type your prompt.
|
||||
|
||||
Metabot will generate the SQL for you, but it won't run the query. This gives you a chance to inspect the code before running it. The native editor is designed to be read-only (so don't worry about Metabot dropping any tables), but you should still check the query to make sure it targets the data you want.
|
||||
Metabot will generate the SQL for you, but it won't run the query. This gives you a chance to inspect the code before running it.
|
||||
|
||||
If you don't specify a specific table in a natural language question, Metabot will only check the first 100 tables in the currently selected database. If your question pertains to tables other than those first 100 tables, Metabot may hallucinate the tables it needs, and the query will fail.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
|
||||
1293
docs/api.html
@@ -80,6 +80,7 @@ Here's what you need to know when syncing Google Sheets:
|
||||
- **Special character replacement**. Some characters just don't play nice with databases (like "?"). When we find these in column names, we'll replace them with "x" to keep things running smoothly.
|
||||
- **Renamed files will create new tables**. If you rename files in your folder or tabs in your sheets, we'll treat them as brand new tables and import them fresh.
|
||||
- **New columns sync automatically**. Adding new columns to your sheets? No problem — they'll show up in Metabase as expected.
|
||||
- **Renaming sheet columns will create new table columns**. If you rename a column in a spreadsheet, Metabase will create a new column with the new name in the synced table. The original column will remain in the table but it'll become empty. You'll be able to hide the old column in Table Metadata settings.
|
||||
- **Empty sheets won't import**. We won't import completely empty sheets or sheets that only have column headers. There needs to be some actual data in there.
|
||||
- **Google Sheets must have unique names**. If files in the synced Google Drive folder (and its subfolders) have the same name (e.g., one sheet is in the root folder, another sheet in a subfolder), the sheets might not sync properly.
|
||||
|
||||
|
||||
@@ -137,8 +137,6 @@ config:
|
||||
embedding-app-origins-interactive: null
|
||||
embedding-app-origins-sdk: ''
|
||||
embedding-homepage: hidden
|
||||
embedding-hub-production-embed-snippet-created: false
|
||||
embedding-hub-test-embed-snippet-created: false
|
||||
embedding-secret-key: null
|
||||
enable-embedding-interactive: false
|
||||
enable-embedding-sdk: false
|
||||
|
||||
@@ -585,24 +585,6 @@ Allow Metabase SDK access to these space delimited origins.
|
||||
|
||||
Embedding homepage status, indicating if it's visible, hidden or has been dismissed.
|
||||
|
||||
### `MB_EMBEDDING_HUB_PRODUCTION_EMBED_SNIPPET_CREATED`
|
||||
|
||||
- Type: boolean
|
||||
- Default: `false`
|
||||
- [Exported as](../installation-and-operation/serialization.md): `embedding-hub-production-embed-snippet-created`.
|
||||
- [Configuration file name](./config-file.md): `embedding-hub-production-embed-snippet-created`
|
||||
|
||||
Indicates if a production embed snippet has been created for tracking in the embedding hub.
|
||||
|
||||
### `MB_EMBEDDING_HUB_TEST_EMBED_SNIPPET_CREATED`
|
||||
|
||||
- Type: boolean
|
||||
- Default: `false`
|
||||
- [Exported as](../installation-and-operation/serialization.md): `embedding-hub-test-embed-snippet-created`.
|
||||
- [Configuration file name](./config-file.md): `embedding-hub-test-embed-snippet-created`
|
||||
|
||||
Indicates if a test embed snippet has been created for tracking in the embedding hub.
|
||||
|
||||
### `MB_EMBEDDING_SECRET_KEY`
|
||||
|
||||
- Type: string
|
||||
|
||||
@@ -43,6 +43,7 @@ Anyone can build a community driver. These are the currently known third-party d
|
||||
| [Neo4j](https://github.com/StronkMan/metabase-neo4j-driver) |  |  |
|
||||
| [Netsuite SuiteAnalytics Connect](https://github.com/ericcj/metabase-netsuite-driver) |  |  |
|
||||
| [Peaka](https://github.com/peakacom/metabase-driver) |  |  |
|
||||
| [SPARQL](https://github.com/jhisse/metabase-sparql-driver) |  |  |
|
||||
| [Teradata](https://github.com/swisscom-bigdata/metabase-teradata-driver) |  |  |
|
||||
|
||||
If you don't see a driver for your database, try looking in the comments of the [issue related to the database](https://github.com/metabase/metabase/labels/Database%2F). You might also find more drivers by searching on GitHub for "Metabase driver".
|
||||
|
||||
@@ -7,7 +7,7 @@ title: Development environment
|
||||
The Metabase application has two basic components:
|
||||
|
||||
1. A backend written in Clojure which contains a REST API as well as all the relevant code for talking to databases and processing queries.
|
||||
2. A frontend written as a Javascript single-page application which provides the web UI.
|
||||
2. A frontend written as a JavaScript single-page application which provides the web UI.
|
||||
|
||||
Both components are built and assembled together into a single JAR file. In the directory where you run the JAR, you can create a JAR file (if Metabase hasn't already created it) and add drivers in there (the drivers are also JARs).
|
||||
|
||||
@@ -147,6 +147,17 @@ You can also start a REPL another way (e.g., through your editor) and then call:
|
||||
To start the server (at `localhost:3000`). This will also set up or migrate your application database. To actually
|
||||
use Metabase, don't forget to start the frontend as well (e.g. with `yarn build-hot`).
|
||||
|
||||
### Multiple Instances
|
||||
|
||||
By default Rspack runs the development server on port `8088`. You can run multiple instances of Metabase on the same machine by specifying a different port for each instance.
|
||||
|
||||
Frontend:
|
||||
- If you are running the frontend with `yarn build-hot`, set the `MB_FRONTEND_DEV_PORT` environment variable: `MB_FRONTEND_DEV_PORT=8089 MB_EDITION=ee yarn build-hot`
|
||||
- If you are building the frontend statically with `yarn build`, there is nothing different to do
|
||||
|
||||
Backend:
|
||||
- Set the `MB_JETTY_PORT` environment variable and `MB_FRONTEND_DEV_PORT` to the same one as for the frontend.
|
||||
|
||||
### The application database
|
||||
|
||||
By default, Metabase uses H2 for its application database, but we recommend using Postgres. This is configured with
|
||||
|
||||
@@ -20,7 +20,22 @@ title: Driver interface changelog
|
||||
- Added `metabase.driver/rename-tables!*` multimethod for atomic table renaming operations. Takes a map of {from-table to-table}
|
||||
pairs that has been topologically sorted.
|
||||
|
||||
- Added the driver multi-methods `metabase.driver/schema-exists?` and `metabase.driver/create-schema-if-needed!` which should be implemented by drivers that support `:schemas` and `:transforms/table`.
|
||||
- Added `metabase.driver/rename-table!` multimethod for table renaming operations. Takes a single from-table to-table pair.
|
||||
Fallbacks to singleton call to `rename-tables!*` if available.
|
||||
|
||||
- Added the driver multi-methods `metabase.driver/schema-exists?` and `metabase.driver/create-schema-if-needed!`
|
||||
which should be implemented by drivers that support `:schemas` and `:transforms/table`.
|
||||
|
||||
- Added `metabase.driver/type->database-type` multimethod that returns the database type for a given Metabase
|
||||
type (from the type hierarchy) as a HoneySQL spec. This method handles general Metabase base types.
|
||||
|
||||
- Added `metabase.driver/insert-from-source!` multimethod that abstracts data insertion from various sources
|
||||
into existing tables. This multimethod dispatches on both the driver and the data source type
|
||||
(`:rows` or `:jsonl-file`). It allows drivers to optimize based on the data source type and returns the number
|
||||
of rows inserted. Default implementations are provided for both `:rows` and `:jsonl-file` source types.
|
||||
|
||||
- Added `metabase.driver/insert-col->val` multimethod to parse values for insertion based on driver, data source type
|
||||
and column definition. Drivers should implement this when their insertion mechanism needs values converted to proper types.
|
||||
|
||||
- `metabase.driver/humanize-connection-error-message` now takes a list of all messages in the exception chain,
|
||||
instead of just the top-level message as a string.
|
||||
|
||||
BIN
docs/documents/images/document-with-comment.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
@@ -7,6 +7,8 @@ summary: Create documents to combine text and charts for long-form analysis. Add
|
||||
|
||||
{% include plans-blockquote.html feature="Documents" %}
|
||||
|
||||

|
||||
|
||||
Documents let you tell a story with your data by combining text, charts, and other content.
|
||||
|
||||
## Create a document
|
||||
@@ -34,7 +36,9 @@ You can add [questions](../questions/start.md) and [models](../data-modeling/mod
|
||||
|
||||
### Adding a chart copies it to the document
|
||||
|
||||
When you add a chart from a collection, Metabase creates a copy in the document. The original question remains in the collection.
|
||||
When you add a chart from a collection, Metabase creates a copy in the document. The original question remains in the collection. Changes to one copy won't affect the other; they're independent.
|
||||
|
||||
If however, you delete the document's copy, that copy is permanently deleted. It can't be recovered from the [trash](../exploration-and-organization/delete-and-restore.md).
|
||||
|
||||
### Charts generated by Metabot only exist in the document
|
||||
|
||||
@@ -74,9 +78,14 @@ You can comment on whole sections like charts, headings, or text blocks. You can
|
||||
You can:
|
||||
|
||||
- React to comments with emojis (which are like pictures masquerading as words). For example, this is a gondola: 🚠.
|
||||
- Mention other items or people in your Metabase (type `@` and then search for who/what you want to mention).
|
||||
- Link to a comment. Click the **three-dot** menu and select **Copy link**.
|
||||
- Resolve comments. Resolved comments appear in a separate tab.
|
||||
|
||||
### 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.
|
||||
|
||||
### Deleting comments
|
||||
|
||||
If you never want to see a comment again:
|
||||
@@ -86,7 +95,7 @@ If you never want to see a comment again:
|
||||
3. Click on the **three-dot** menu.
|
||||
4. Select **Delete**.
|
||||
|
||||
This deletion is permanent. You'll never see that comment again.
|
||||
This deletion is permanent.
|
||||
|
||||
## Printing documents
|
||||
|
||||
@@ -101,7 +110,6 @@ Documents and dashboards both have their use cases.
|
||||
Documents work best for:
|
||||
|
||||
- Telling a story with data
|
||||
- Point-in-time analysis
|
||||
- Opening analysis for comments
|
||||
- Exploratory analysis
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -360,7 +365,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>
|
||||
```
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 207 KiB |
@@ -4,6 +4,8 @@ title: "Interactive embedding quickstart"
|
||||
|
||||
# Interactive embedding quickstart
|
||||
|
||||
> If you are just starting out with Metabase embedding, consider using [Embedded Analytics JS](./embedded-analytics-js.md) - an improved, more customizable option for embedding interactive Metabase elements. Interactive embedding remains fully supported.
|
||||
|
||||
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.
|
||||
|
||||
{% include shared/in-page-promo-embedding-workshop.html %}
|
||||
@@ -27,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
|
||||
|
||||
@@ -39,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).
|
||||
|
||||
@@ -167,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.
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ redirect_from:
|
||||
|
||||
**Interactive embedding** is what you want if you want to offer [multi-tenant, self-service analytics](https://www.metabase.com/learn/metabase-basics/embedding/multi-tenant-self-service-analytics).
|
||||
|
||||
> If you are just starting out with Metabase embedding, consider using [Embedded Analytics JS](./embedded-analytics-js.md) instead of interactive embedding - it's an improved, more customizable option for embedding interactive Metabase elements. Interactive embedding remains fully supported.
|
||||
|
||||
Interactive embedding is the only type of embedding that integrates with your [permissions](../permissions/introduction.md) and [SSO](../people-and-groups/start.md#authentication) to give people the right level of access to [query](https://www.metabase.com/glossary/query-builder) and [drill-down](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) into your data.
|
||||
|
||||
## Interactive embedding demo
|
||||
@@ -38,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
|
||||
|
||||
@@ -129,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:
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ description: Customize the UI components in your interactive Metabase embed by a
|
||||
|
||||
To change the interface of your interactive embed, you can add parameters to the end of your embedding URL. If you want to change the colors or fonts in your embed, see [Customizing appearance](../configuring-metabase/appearance.md).
|
||||
|
||||
> If you are just starting out with Metabase embedding, consider using [Embedded Analytics JS](./embedded-analytics-js.md) instead of interactive embedding - it's an improved, more customizable option for embedding interactive Metabase elements. Interactive embedding remains fully supported.
|
||||
|
||||
For example, you can disable Metabase's [top nav bar](#top_nav) and [side nav menu](#side_nav) like this:
|
||||
|
||||
```
|
||||
|
||||
@@ -12,23 +12,11 @@ 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.
|
||||
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).
|
||||
|
||||
**When to use Embedded analytics JS**: you’re not using React (or want a drop‑in script) and want to embed Metabase components with per‑component controls and theming.
|
||||
|
||||
## Interactive embedding
|
||||
|
||||
Interactive embedding is the only kind of embedding that [integrates with SSO and data permissions](./interactive-embedding.md) to enable true self-service access to the underlying data.
|
||||
|
||||
**When to use interactive embedding**: you want to [offer multi-tenant, self-service analytics](https://www.metabase.com/blog/why-full-app-embedding). With interactive embedding, people can create their own questions, dashboards, models, and more, with row and column security policies determining which data they can access.
|
||||
**When to use Embedded analytics JS**: You want to [offer multi-tenant, self-service analytics](https://www.metabase.com/blog/why-full-app-embedding), you’re not using React (or want a drop‑in script) and want to embed Metabase components with per‑component controls and theming.
|
||||
|
||||
## Static embedding
|
||||
|
||||
@@ -42,31 +30,41 @@ If you'd like to share your data with the good people of the internet, admins ca
|
||||
|
||||
**When to use public links and embeds**: public links and embeds are good for one-off charts and dashboards. Admins can use them when you just need to show someone a chart or dashboard without giving people access to your Metabase. And you don't care who sees the data; you want to make those stats available to everyone.
|
||||
|
||||
## Interactive embedding
|
||||
|
||||
[Interactive embedding](./interactive-embedding.md) allows you to embed the entire Metabase app in an iframe, and integrate Metabase SSO with your app's authentication.
|
||||
|
||||
## 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.
|
||||
|
||||
## Switching from static to interactive embedding
|
||||
## Switching from static embedding to Embedded Analytics JS
|
||||
|
||||
[Interactive embedding](./interactive-embedding.md) requires authentication via single sign-on (SSO), so you'll need to set that up both in your Metabase and in your application's server. Check out our [Interactive embedding quick start](../embedding/interactive-embedding-quick-start-guide.md).
|
||||
[Embedded Analytics JS](./embedded-analytics-js.md) requires authentication via single sign-on (SSO), so you'll need to set that up both in your Metabase and in your application's server. Check out our [Modular embedding authentication](../embedding/sdk/authentication.md).
|
||||
|
||||
## Further reading
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
title: Account settings
|
||||
summary: Update your profile, change your password, view login history, manage notifications, and customize your Metabase experience.
|
||||
redirect_from:
|
||||
- /docs/latest/users-guide/account-settings
|
||||
---
|
||||
@@ -34,3 +35,5 @@ This isn't an in-Metabase setting, but just so you know: you can disable UI anim
|
||||
## Notifications
|
||||
|
||||
If you subscribe or are added to dashboard subscriptions or alerts, you’ll be able to manage those notifications here (as well as on the relevant question or dashboard themselves).
|
||||
|
||||
Metabase excludes notificatione for [comments](../documents/introduction.md#comment-notifications) from this page.
|
||||
|
||||
@@ -12,10 +12,14 @@ With database routing, an admin can build a question once using one database, an
|
||||
Database routing is useful for:
|
||||
|
||||
- Managing interactive embedding setups where each customer has their own database with identical schemas.
|
||||
|
||||
Database routing can't be used with static embedding, because database routing requires people who use the embedded questions and dashboards to have a Metabase account. Without a Metabase account, Metabase can't route the queries because it doesn't know who is viewing the embed.
|
||||
|
||||
- Switching between dev and prod data warehouses.
|
||||
- Changing the target data warehouse for certain teams.
|
||||
- Managing separate connections to the same data warehouse, with each connection having separate privileges. This connection management is akin to [connection impersonation](./impersonation.md) for databases that prevent the same connection from changing roles.
|
||||
|
||||
|
||||
## Database routing limitations
|
||||
|
||||
> Database routing is **not supported** on ClickHouse, Oracle, Spark SQL, and Vertica.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,7 +25,7 @@ To fix this problem you'll need answers to these questions:
|
||||
Once you have these answers, look for cases like these:
|
||||
|
||||
1. Your question or chart is comparing or sorting values with inconsistent or missing time zones. For example, if a flight's departure and arrival times are reported in local time, it can appear to arrive before it has left.
|
||||
2. Your question is aggregating timetsamps with different time zones: for example, the "daily" totals for your website's traffic include more than 24 hours worth of data because you are using the local dates from East Asia, Europe, and the Americas.
|
||||
2. Your question is aggregating timestamps with different time zones: for example, the "daily" totals for your website's traffic include more than 24 hours worth of data because you are using the local dates from East Asia, Europe, and the Americas.
|
||||
|
||||
Once you think you have identified a problem, drill down to understand exactly what time zone conversion is causing the underlying problem. For example, suppose you're looking at a time series with daily values; if your error is happening with weekly totals, you can:
|
||||
|
||||
|
||||
165
e2e/support/document-initial-data.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
ORDERS_BY_YEAR_QUESTION_ID,
|
||||
ORDERS_COUNT_QUESTION_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
export const DOCUMENT_WITH_TWO_CARDS = {
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Testing drag and drop functionality",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
_id: "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
height: 350,
|
||||
minHeight: 280,
|
||||
_id: "2",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "2a",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Some text between cards",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
_id: "3",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
height: 350,
|
||||
minHeight: 280,
|
||||
_id: "4",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_COUNT_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "4a",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
_id: "5",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
};
|
||||
|
||||
export const DOCUMENT_WITH_THREE_CARDS_AND_COLUMNS = {
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Advanced drag and drop scenarios",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
_id: "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
height: 350,
|
||||
minHeight: 280,
|
||||
_id: "2",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "flexContainer",
|
||||
attrs: {
|
||||
_id: "2a",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "2a1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_COUNT_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "2a2",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Standalone card below",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
_id: "3",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
height: 350,
|
||||
minHeight: 280,
|
||||
_id: "4",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_BY_YEAR_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "4a",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
_id: "5",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
};
|
||||
@@ -38,4 +38,4 @@ export { getCurrentUser } from "./getCurrentUser";
|
||||
export { remapDisplayValueToFK } from "./remapDisplayValueToFK";
|
||||
export { updateComment } from "./updateComment";
|
||||
export { updateDashboardCards } from "./updateDashboardCards";
|
||||
export { updateSetting } from "./updateSetting";
|
||||
export { updateSetting, updateEnterpriseSetting } from "./updateSetting";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Settings } from "metabase-types/api";
|
||||
import type { EnterpriseSettings, Settings } from "metabase-types/api";
|
||||
|
||||
export const updateSetting = <
|
||||
TKey extends keyof Settings,
|
||||
@@ -9,3 +9,13 @@ export const updateSetting = <
|
||||
): Cypress.Chainable<Cypress.Response<never>> => {
|
||||
return cy.request<never>("PUT", `/api/setting/${setting}`, { value });
|
||||
};
|
||||
|
||||
export const updateEnterpriseSetting = <
|
||||
TKey extends keyof EnterpriseSettings,
|
||||
TValue extends EnterpriseSettings[TKey],
|
||||
>(
|
||||
setting: TKey,
|
||||
value: TValue,
|
||||
): Cypress.Chainable<Cypress.Response<never>> => {
|
||||
return cy.request<never>("PUT", `/api/setting/${setting}`, { value });
|
||||
};
|
||||
|
||||
223
e2e/support/helpers/e2e-codemirror-helpers.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
type TypeOptions = {
|
||||
delay?: number;
|
||||
focus?: boolean;
|
||||
allowFastSet?: boolean;
|
||||
};
|
||||
|
||||
export function codeMirrorHelpers<T extends object>(testId: string, extra: T) {
|
||||
const helpers = {
|
||||
get() {
|
||||
cy.findAllByTestId("loading-indicator").should("not.exist");
|
||||
return cy.get(`[data-testid=${testId}] .cm-content`);
|
||||
},
|
||||
focus() {
|
||||
helpers.get().should("be.visible").click("right");
|
||||
helpers.get().get(".cm-editor").should("have.class", "cm-focused");
|
||||
return helpers;
|
||||
},
|
||||
blur() {
|
||||
helpers.get().get(".cm-content").blur();
|
||||
return helpers;
|
||||
},
|
||||
selectAll() {
|
||||
const isMac = Cypress.platform === "darwin";
|
||||
const metaKey = isMac ? "Meta" : "Control";
|
||||
helpers.focus();
|
||||
cy.realPress([metaKey, "A"]);
|
||||
return helpers;
|
||||
},
|
||||
clear() {
|
||||
helpers.selectAll();
|
||||
cy.realPress(["Backspace"]);
|
||||
return helpers;
|
||||
},
|
||||
type(
|
||||
text: string,
|
||||
{ focus = true, delay = 10, allowFastSet = false }: TypeOptions = {},
|
||||
) {
|
||||
if (focus) {
|
||||
helpers.focus();
|
||||
}
|
||||
|
||||
if (allowFastSet) {
|
||||
// Enter the formula in one go
|
||||
// HACK: we do invoke("text") instead of type() because type() does not work on
|
||||
// CodeMirror elements in Cypress. realType() would work but some of the formulas
|
||||
// contain special characters that are not supported by realType().
|
||||
helpers.get().invoke("text", text);
|
||||
|
||||
// invoke("text") does not trigger the validator, so we need to trigger it manually
|
||||
// by typing something
|
||||
helpers.type(" {backspace}");
|
||||
|
||||
return helpers;
|
||||
}
|
||||
|
||||
const isMac = Cypress.platform === "darwin";
|
||||
const metaKey = isMac ? "Meta" : "Control";
|
||||
|
||||
const parts = text.replaceAll("{{", "{{}{{}").split(/(\{[^}]+\})/);
|
||||
|
||||
function insert(text: string) {
|
||||
cy.realType(text, { pressDelay: delay });
|
||||
}
|
||||
|
||||
// HACK: realType does not accept a lot of common escape sequences,
|
||||
// so we implement them manually here for the native editor.
|
||||
parts.forEach((part) => {
|
||||
switch (part.toLowerCase()) {
|
||||
case "":
|
||||
return;
|
||||
|
||||
case "{clear}":
|
||||
return helpers.clear();
|
||||
|
||||
case "{selectall}":
|
||||
return helpers.selectAll();
|
||||
|
||||
case "{leftarrow}":
|
||||
return cy.realPress(["ArrowLeft"]);
|
||||
|
||||
case "{rightarrow}":
|
||||
return cy.realPress(["ArrowRight"]);
|
||||
|
||||
case "{downarrow}":
|
||||
return cy.realPress(["ArrowDown"]);
|
||||
|
||||
case "{uparrow}":
|
||||
return cy.realPress(["ArrowUp"]);
|
||||
|
||||
case "{enter}":
|
||||
return cy.realPress(["Enter"]);
|
||||
|
||||
case "{home}":
|
||||
case "{movetostart}":
|
||||
return cy.realPress(["Home"]);
|
||||
|
||||
case "{end}":
|
||||
case "{movetoend}":
|
||||
return cy.realPress(["End"]);
|
||||
|
||||
case "{backspace}":
|
||||
return cy.realPress(["Backspace"]);
|
||||
|
||||
case "{tab}":
|
||||
return cy.realPress(["Tab"]);
|
||||
|
||||
case "{nextcompletion}":
|
||||
cy.wait(50);
|
||||
return cy.realPress([metaKey, "j"]);
|
||||
|
||||
case "{prevcompletion}":
|
||||
cy.wait(50);
|
||||
return cy.realPress([metaKey, "k"]);
|
||||
|
||||
case "{{}":
|
||||
return cy.realType("{");
|
||||
}
|
||||
|
||||
if (part.startsWith("{") && part.endsWith("}")) {
|
||||
// unknown escape sequence, let's try typing it
|
||||
insert("{");
|
||||
insert(part.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// realType does not support → arrow, so we replace it with ->, which the editor
|
||||
// expands into →
|
||||
const unexpanded = part.replaceAll(/→/g, "->");
|
||||
const alphabet =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789^[]()-,.;_!@#$%&*+=/<>\" ':;\\\n{}";
|
||||
if (unexpanded.split("").some((char) => !alphabet.includes(char))) {
|
||||
throw new Error(
|
||||
`unknown character in codeMirrorHelpers.type in ${part}`,
|
||||
);
|
||||
}
|
||||
|
||||
insert(unexpanded);
|
||||
});
|
||||
|
||||
return helpers;
|
||||
},
|
||||
textbox() {
|
||||
return helpers.get().get("[role='textbox']");
|
||||
},
|
||||
value() {
|
||||
// Get the multiline text content of the editor
|
||||
return helpers
|
||||
.textbox()
|
||||
.get(".cm-line")
|
||||
.then((lines) => {
|
||||
const text: string[] = [];
|
||||
lines.each((_, line) => {
|
||||
const placeholder = line.querySelector(".cm-placeholder");
|
||||
if (placeholder) {
|
||||
return;
|
||||
}
|
||||
text.push(line.textContent ?? "");
|
||||
});
|
||||
return text.join("\n");
|
||||
});
|
||||
},
|
||||
completions() {
|
||||
return cy.get(".cm-tooltip-autocomplete").should("be.visible");
|
||||
},
|
||||
completion(label: string) {
|
||||
return cy.get(".cm-completionLabel").contains(label).parent();
|
||||
},
|
||||
acceptCompletion(key: "enter" | "tab" = "enter") {
|
||||
helpers.completions().should("be.visible");
|
||||
|
||||
// Avoid flakiness with CodeMirror not accepting the suggestion immediately
|
||||
cy.wait(300);
|
||||
helpers.type(`{${key}}`, { focus: false });
|
||||
},
|
||||
selectCompletion(name: string) {
|
||||
helpers.completions().should("be.visible");
|
||||
|
||||
// Avoid flakiness with CodeMirror not accepting the suggestion immediately
|
||||
cy.wait(300);
|
||||
helpers.completion(name).click();
|
||||
},
|
||||
paste(content: string) {
|
||||
helpers.textbox().then((el) => {
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.setData("text/plain", content);
|
||||
|
||||
const pasteEvent = new ClipboardEvent("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
});
|
||||
|
||||
el[0].dispatchEvent(pasteEvent);
|
||||
});
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
|
||||
return helpers;
|
||||
}
|
||||
|
||||
export function codeMirrorEditor() {
|
||||
return cy.get(".cm-content");
|
||||
}
|
||||
|
||||
export function codeMirrorValue() {
|
||||
// Get the multiline text content of the editor
|
||||
return codeMirrorEditor()
|
||||
.get("[role='textbox']")
|
||||
.get(".cm-line")
|
||||
.then((lines) => {
|
||||
const text: string[] = [];
|
||||
lines.each((_, line) => {
|
||||
text.push(line.textContent ?? "");
|
||||
});
|
||||
const value = text.join("\n");
|
||||
const placeholder = "SELECT * FROM TABLE_NAME";
|
||||
if (value === placeholder) {
|
||||
return "";
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
@@ -21,16 +21,22 @@ function getDocumentNodeButton({
|
||||
targetId,
|
||||
childTargetId,
|
||||
hasComments,
|
||||
isCardEmbedNode,
|
||||
}: {
|
||||
targetId: DocumentId;
|
||||
childTargetId: string;
|
||||
hasComments?: boolean;
|
||||
isCardEmbedNode?: boolean;
|
||||
}) {
|
||||
const threadUrl = `/document/${targetId}/comments/${childTargetId}${hasComments ? "" : "?new=true"}`;
|
||||
|
||||
return getDocumentNodeButtons().filter(
|
||||
(_, element) => element.getAttribute("href") === threadUrl,
|
||||
);
|
||||
if (isCardEmbedNode) {
|
||||
return cy.findByRole("button", { name: "Comments" });
|
||||
} else {
|
||||
return getDocumentNodeButtons().filter(
|
||||
(_, element) => element.getAttribute("href") === threadUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getDocumentNodeButtons() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { popover } from "e2e/support/helpers/e2e-ui-elements-helpers";
|
||||
|
||||
import { codeMirrorHelpers } from "./e2e-codemirror-helpers";
|
||||
|
||||
export function expressionEditorWidget() {
|
||||
return cy.findByTestId("expression-editor");
|
||||
}
|
||||
@@ -8,9 +10,6 @@ export function expressionEditorTextfield() {
|
||||
return CustomExpressionEditor.get();
|
||||
}
|
||||
|
||||
const isMac = Cypress.platform === "darwin";
|
||||
const metaKey = isMac ? "Meta" : "Control";
|
||||
|
||||
export function enterCustomColumnDetails({
|
||||
formula,
|
||||
name,
|
||||
@@ -71,224 +70,43 @@ export function enterCustomColumnDetails({
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomExpressionEditor = {
|
||||
get() {
|
||||
return cy.findByTestId("custom-expression-query-editor");
|
||||
},
|
||||
/**
|
||||
* @param {string} text the formula to type
|
||||
* @param {Object} options
|
||||
* @param {boolean} [options.focus]
|
||||
* true by default. Set to false to stop the helper from explicitly focussing the formula input.
|
||||
* @param {boolean} [options.allowFastSet]
|
||||
* true by default. However, if you need to examine the popover in the test, it should be set to false so the popover is not dismissed
|
||||
* Because CodeMirror uses a contenteditable div, and it is not possible to use cy.type() on it, we emulate .type with realPress.
|
||||
* This does not always work, since realPress() does not support all characters. Setting this to true will enable an escape hatch
|
||||
* that uses cy.invoke('text') under the hood, to allow for formulas that contain unsupported characters.
|
||||
* This has some other sideeffects however, so use it sparingly.
|
||||
*/
|
||||
type(
|
||||
text: string,
|
||||
{
|
||||
allowFastSet = false,
|
||||
focus = true,
|
||||
delay = 0,
|
||||
}: {
|
||||
focus?: boolean;
|
||||
allowFastSet?: boolean;
|
||||
delay?: number;
|
||||
} = {},
|
||||
) {
|
||||
if (focus) {
|
||||
CustomExpressionEditor.focus();
|
||||
}
|
||||
|
||||
if (allowFastSet) {
|
||||
// Enter the formula in one go
|
||||
// HACK: we do invoke("text") instead of type() because type() does not work on
|
||||
// CodeMirror elements in Cypress. realType() would work but some of the formulas
|
||||
// contain special characters that are not supported by realType().
|
||||
CustomExpressionEditor.get().findByRole("textbox").invoke("text", text);
|
||||
|
||||
// invoke("text") does not trigger the validator, so we need to trigger it manually
|
||||
// by typing something
|
||||
CustomExpressionEditor.type(" {backspace}");
|
||||
|
||||
export const CustomExpressionEditor = codeMirrorHelpers(
|
||||
"custom-expression-query-editor",
|
||||
{
|
||||
formatButton() {
|
||||
return cy.findByLabelText("Auto-format");
|
||||
},
|
||||
format() {
|
||||
CustomExpressionEditor.formatButton().should("be.visible");
|
||||
CustomExpressionEditor.formatButton().click();
|
||||
return CustomExpressionEditor;
|
||||
}
|
||||
|
||||
const parts = text.replaceAll("{{", "{{}{{}").split(/(\{[^}]+\})/);
|
||||
|
||||
parts.forEach((part) => {
|
||||
cy.wait(delay);
|
||||
switch (part.toLowerCase()) {
|
||||
case "":
|
||||
return;
|
||||
|
||||
case "{clear}":
|
||||
return CustomExpressionEditor.clear();
|
||||
|
||||
case "{selectall}":
|
||||
return CustomExpressionEditor.selectAll();
|
||||
|
||||
case "{leftarrow}":
|
||||
return cy.realPress(["ArrowLeft"]);
|
||||
|
||||
case "{rightarrow}":
|
||||
return cy.realPress(["ArrowRight"]);
|
||||
|
||||
case "{downarrow}":
|
||||
return cy.realPress(["ArrowDown"]);
|
||||
|
||||
case "{uparrow}":
|
||||
return cy.realPress(["ArrowUp"]);
|
||||
|
||||
case "{enter}":
|
||||
return cy.realPress(["Enter"]);
|
||||
|
||||
case "{home}":
|
||||
case "{movetostart}":
|
||||
return cy.realPress(["Home"]);
|
||||
|
||||
case "{end}":
|
||||
case "{movetoend}":
|
||||
return cy.realPress(["End"]);
|
||||
|
||||
case "{backspace}":
|
||||
return cy.realPress(["Backspace"]);
|
||||
|
||||
case "{tab}":
|
||||
return cy.realPress(["Tab"]);
|
||||
|
||||
case "{nextcompletion}":
|
||||
cy.wait(50);
|
||||
return cy.realPress([metaKey, "j"]);
|
||||
|
||||
case "{prevcompletion}":
|
||||
cy.wait(50);
|
||||
return cy.realPress([metaKey, "k"]);
|
||||
|
||||
case "{{}":
|
||||
return cy.realType("{");
|
||||
}
|
||||
|
||||
if (part.startsWith("{") && part.endsWith("}")) {
|
||||
// unknown escape sequence, let's try typing it
|
||||
throw new Error(
|
||||
`unknown escape sequence in CustomExpressionEditor.type: ${part}`,
|
||||
);
|
||||
}
|
||||
|
||||
// realType does not support → arrow, so we replace it with ->, which the editor
|
||||
// expands into →
|
||||
const unexpanded = part.replaceAll(/→/g, "->");
|
||||
|
||||
const alphabet =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789^[]()-,.;_!@#$%&*+=/<>\" ':;\\\n";
|
||||
if (unexpanded.split("").some((char) => !alphabet.includes(char))) {
|
||||
throw new Error(
|
||||
`unknown character in CustomExpressionEditor.type in ${part}`,
|
||||
);
|
||||
}
|
||||
|
||||
cy.realType(unexpanded, { delay });
|
||||
});
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
focus() {
|
||||
CustomExpressionEditor.get().click("right");
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
blur() {
|
||||
// click outside the expression editor
|
||||
cy.findByTestId("expression-editor").click("bottomRight", { force: true });
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
formatButton() {
|
||||
return cy.findByLabelText("Auto-format");
|
||||
},
|
||||
format() {
|
||||
CustomExpressionEditor.formatButton().should("be.visible");
|
||||
CustomExpressionEditor.formatButton().click();
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
selectAll() {
|
||||
CustomExpressionEditor.focus();
|
||||
cy.realPress([metaKey, "A"]);
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
clear() {
|
||||
CustomExpressionEditor.selectAll();
|
||||
cy.realPress(["Backspace"]);
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
textbox() {
|
||||
return CustomExpressionEditor.get().get("[role='textbox']");
|
||||
},
|
||||
value() {
|
||||
// Get the multiline text content of the editor
|
||||
return CustomExpressionEditor.textbox()
|
||||
.get(".cm-line")
|
||||
.then((lines) => {
|
||||
const text: string[] = [];
|
||||
lines.each((_, line) => {
|
||||
text.push(line.textContent ?? "");
|
||||
});
|
||||
const value = text.join("\n");
|
||||
const placeholder = "Type your expression, press '[' for columns…";
|
||||
if (value === placeholder) {
|
||||
return "";
|
||||
}
|
||||
return value;
|
||||
},
|
||||
helpTextHeader() {
|
||||
return cy.findByTestId("expression-helper-popover-structure");
|
||||
},
|
||||
helpText() {
|
||||
return cy.findByTestId("expression-helper");
|
||||
},
|
||||
nameInput() {
|
||||
return cy.findByTestId("expression-name");
|
||||
},
|
||||
functionBrowser() {
|
||||
return cy.findByTestId("expression-editor-function-browser");
|
||||
},
|
||||
completions() {
|
||||
return cy.findByTestId("custom-expression-editor-suggestions");
|
||||
},
|
||||
completion(name: string) {
|
||||
return CustomExpressionEditor.completions()
|
||||
.findAllByRole("option")
|
||||
.contains(name)
|
||||
.first();
|
||||
},
|
||||
blur() {
|
||||
cy.findByTestId("expression-editor").click("bottomRight", {
|
||||
force: true,
|
||||
});
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
},
|
||||
completions() {
|
||||
return cy.findByTestId("custom-expression-editor-suggestions");
|
||||
},
|
||||
completion(name: string) {
|
||||
return CustomExpressionEditor.completions()
|
||||
.findAllByRole("option")
|
||||
.contains(name)
|
||||
.first();
|
||||
},
|
||||
acceptCompletion(key: "enter" | "tab" = "enter") {
|
||||
CustomExpressionEditor.completions().should("be.visible");
|
||||
|
||||
// Avoid flakiness with CodeMirror not accepting the suggestion immediately
|
||||
cy.wait(300);
|
||||
CustomExpressionEditor.type(`{${key}}`, { focus: false });
|
||||
},
|
||||
selectCompletion(name: string) {
|
||||
CustomExpressionEditor.completions().should("be.visible");
|
||||
|
||||
// Avoid flakiness with CodeMirror not accepting the suggestion immediately
|
||||
cy.wait(300);
|
||||
CustomExpressionEditor.completion(name).click();
|
||||
},
|
||||
helpTextHeader() {
|
||||
return cy.findByTestId("expression-helper-popover-structure");
|
||||
},
|
||||
helpText() {
|
||||
return cy.findByTestId("expression-helper");
|
||||
},
|
||||
paste(content: string) {
|
||||
CustomExpressionEditor.textbox().then((el) => {
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.setData("text/plain", content);
|
||||
|
||||
const pasteEvent = new ClipboardEvent("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
});
|
||||
|
||||
el[0].dispatchEvent(pasteEvent);
|
||||
});
|
||||
},
|
||||
nameInput() {
|
||||
return cy.findByTestId("expression-name");
|
||||
},
|
||||
functionBrowser() {
|
||||
return cy.findByTestId("expression-editor-function-browser");
|
||||
},
|
||||
};
|
||||
);
|
||||
|
||||
@@ -37,10 +37,22 @@ export const documentMetabotSuggestionItem = (name: RegExp | string) =>
|
||||
export const commandSuggestionItem = (name: RegExp | string) =>
|
||||
commandSuggestionDialog().findByRole("option", { name });
|
||||
|
||||
export const getDocumentCard = (name: string) =>
|
||||
documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.contains('[data-testid="document-card-embed"]', name);
|
||||
export const getDocumentCard = (name: string, expectCardExists = true) => {
|
||||
const cardsResult = documentContent()
|
||||
.findAllByTestId("card-embed-title")
|
||||
.filter((index, el) => {
|
||||
// Filter elements based on custom logic, e.g., text content, class, etc.
|
||||
return el.innerText === name;
|
||||
})
|
||||
.should("have.length", expectCardExists ? 1 : 0);
|
||||
|
||||
return expectCardExists
|
||||
? cardsResult.closest('[data-testid="document-card-embed"]')
|
||||
: cardsResult;
|
||||
};
|
||||
|
||||
export const getDocumentCardResizeContainer = (name: string) =>
|
||||
getDocumentCard(name).closest('[data-type="resizeNode"]');
|
||||
|
||||
export const assertDocumentCardVizType = (name: string, type: string) =>
|
||||
getDocumentCard(name).find(`[data-viz-ui-name=${type}]`);
|
||||
@@ -102,3 +114,124 @@ const visitDocumentById = (id: DocumentId) => {
|
||||
|
||||
cy.wait(`@${alias}`);
|
||||
};
|
||||
|
||||
export function dragAndDropCardOnAnotherCard(
|
||||
sourceCardTitle: string,
|
||||
targetCardTitle: string,
|
||||
{
|
||||
side = "left",
|
||||
}: {
|
||||
side?: "left" | "right";
|
||||
} = {},
|
||||
) {
|
||||
// Perform drag and drop: drag sourceCard card onto targetCard
|
||||
getDocumentCard(sourceCardTitle)
|
||||
.should("exist")
|
||||
.then(($ordersCard) => {
|
||||
getDocumentCard(targetCardTitle)
|
||||
.should("exist")
|
||||
.then(($ordersCountCard) => {
|
||||
const sourceRect = $ordersCard[0].getBoundingClientRect();
|
||||
|
||||
// Start drag from center of Orders card
|
||||
getDocumentCard(sourceCardTitle).trigger("mousedown", {
|
||||
x: 10,
|
||||
y: 10,
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
const dataTransfer = new DataTransfer();
|
||||
getDocumentCard(sourceCardTitle).trigger("dragstart", {
|
||||
dataTransfer,
|
||||
clientX: sourceRect.left + 10,
|
||||
clientY: sourceRect.top + 10,
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const targetRect = $ordersCountCard[0].getBoundingClientRect();
|
||||
// Calculate position for target card side drop (20% from left or 20% from right based on 'side' param)
|
||||
const sideX =
|
||||
targetRect.left + targetRect.width * (side === "left" ? 0.2 : 0.8);
|
||||
const centerY = targetRect.top + 10;
|
||||
documentContent().trigger("mousemove", {
|
||||
clientX: sideX,
|
||||
clientY: centerY,
|
||||
scrollBehavior: false,
|
||||
});
|
||||
getDocumentCard(targetCardTitle).trigger("dragover", {
|
||||
clientX: sideX,
|
||||
clientY: centerY,
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
|
||||
getDocumentCard(sourceCardTitle).realMouseUp({
|
||||
scrollBehavior: false,
|
||||
});
|
||||
getDocumentCard(targetCardTitle).trigger("drop", {
|
||||
dataTransfer,
|
||||
clientX: sideX,
|
||||
clientY: centerY,
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
getDocumentCard(sourceCardTitle).trigger("dragend", {
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function documentUndo() {
|
||||
const macOSX = Cypress.platform === "darwin";
|
||||
|
||||
documentContent()
|
||||
.get('[contenteditable="true"]')
|
||||
.click()
|
||||
.type(macOSX ? "{cmd+z}" : "{ctrl+z}", {
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDragHandleForDocumentResizeNode(element: Cypress.Chainable) {
|
||||
return element.findByTestId("resize-node-drag-handle");
|
||||
}
|
||||
|
||||
export function documentDoDrag(
|
||||
_handle: Cypress.Chainable<JQuery<HTMLElement>>,
|
||||
diff: { x?: number; y?: number },
|
||||
) {
|
||||
_handle.then((handle) => {
|
||||
const { x: deltaX = 0, y: deltaY = 0 } = diff;
|
||||
|
||||
const rect = handle[0].getBoundingClientRect();
|
||||
|
||||
cy.log(`x: ${rect.x}, y: ${rect.y}, diff: ${diff}`);
|
||||
|
||||
cy.wrap(handle).trigger("mousedown", {
|
||||
button: 0,
|
||||
clientX: rect.x,
|
||||
clientY: rect.y,
|
||||
force: true,
|
||||
});
|
||||
cy.get("body")
|
||||
.trigger("mousemove", {
|
||||
button: 0,
|
||||
clientX: rect.x + deltaX,
|
||||
clientY: rect.y + deltaY,
|
||||
force: true,
|
||||
})
|
||||
.trigger("mouseup");
|
||||
});
|
||||
}
|
||||
|
||||
export function getFlexContainerForCard(name: string) {
|
||||
return getDocumentCard(name).closest('[data-type="flexContainer"]');
|
||||
}
|
||||
|
||||
export function getResizeHandlesForFlexContianer(element: Cypress.Chainable) {
|
||||
return element.findAllByTestId("flex-container-drag-handle");
|
||||
}
|
||||
|
||||
@@ -1,42 +1,6 @@
|
||||
import { codeMirrorHelpers } from "./e2e-codemirror-helpers";
|
||||
import { popover } from "./e2e-ui-elements-helpers";
|
||||
|
||||
function nativeEditor() {
|
||||
cy.findAllByTestId("loading-indicator").should("not.exist");
|
||||
return cy.get("[data-testid=native-query-editor] .cm-content");
|
||||
}
|
||||
|
||||
function focusNativeEditor() {
|
||||
nativeEditor().should("be.visible").click();
|
||||
|
||||
nativeEditor().get(".cm-editor").should("have.class", "cm-focused");
|
||||
|
||||
return nativeEditor();
|
||||
}
|
||||
|
||||
function blurNativeEditor() {
|
||||
nativeEditor().get(".cm-editor").blur();
|
||||
}
|
||||
|
||||
function nativeEditorCompletions() {
|
||||
return cy.get(".cm-tooltip-autocomplete").should("be.visible");
|
||||
}
|
||||
|
||||
function nativeEditorCompletion(label: string) {
|
||||
return cy.get(".cm-completionLabel").contains(label).parent();
|
||||
}
|
||||
|
||||
function nativeEditorSelectAll() {
|
||||
const isMac = Cypress.platform === "darwin";
|
||||
const metaKey = isMac ? "Meta" : "Control";
|
||||
focusNativeEditor().realPress([metaKey, "A"]);
|
||||
cy.get(".cm-selectionBackground").should("exist");
|
||||
}
|
||||
|
||||
function clearNativeEditor() {
|
||||
nativeEditorSelectAll();
|
||||
cy.realPress(["Backspace"]);
|
||||
}
|
||||
|
||||
export function nativeEditorDataSource() {
|
||||
return cy.findAllByTestId("gui-builder-data").first();
|
||||
}
|
||||
@@ -46,139 +10,7 @@ export function selectNativeEditorDataSource(name: string) {
|
||||
popover().findByText(name).click();
|
||||
}
|
||||
|
||||
type TypeOptions = {
|
||||
delay?: number;
|
||||
focus?: boolean;
|
||||
};
|
||||
|
||||
function nativeEditorType(
|
||||
text: string,
|
||||
{ delay = 10, focus = true }: TypeOptions = {},
|
||||
) {
|
||||
if (focus) {
|
||||
focusNativeEditor();
|
||||
}
|
||||
|
||||
const isMac = Cypress.platform === "darwin";
|
||||
const metaKey = isMac ? "Meta" : "Control";
|
||||
|
||||
const parts = text.replaceAll("{{", "{{}{{}").split(/(\{[^}]+\})/);
|
||||
|
||||
function type(text: string) {
|
||||
cy.realType(text, {
|
||||
pressDelay: delay,
|
||||
});
|
||||
}
|
||||
|
||||
// HACK: realType does not accept a lot of common escape sequences,
|
||||
// so we implement them manually here for the native editor.
|
||||
parts.forEach((part) => {
|
||||
switch (part.toLowerCase()) {
|
||||
case "":
|
||||
return;
|
||||
|
||||
case "{clear}":
|
||||
return clearNativeEditor();
|
||||
|
||||
case "{selectall}":
|
||||
return nativeEditorSelectAll();
|
||||
|
||||
case "{leftarrow}":
|
||||
return cy.realPress(["ArrowLeft"]);
|
||||
|
||||
case "{rightarrow}":
|
||||
return cy.realPress(["ArrowRight"]);
|
||||
|
||||
case "{downarrow}":
|
||||
return cy.realPress(["ArrowDown"]);
|
||||
|
||||
case "{uparrow}":
|
||||
return cy.realPress(["ArrowUp"]);
|
||||
|
||||
case "{enter}":
|
||||
return cy.realPress(["Enter"]);
|
||||
|
||||
case "{home}":
|
||||
case "{movetostart}":
|
||||
return cy.realPress(["Home"]);
|
||||
|
||||
case "{end}":
|
||||
case "{movetoend}":
|
||||
return cy.realPress(["End"]);
|
||||
|
||||
case "{backspace}":
|
||||
return cy.realPress(["Backspace"]);
|
||||
|
||||
case "{tab}":
|
||||
return cy.realPress(["Tab"]);
|
||||
|
||||
case "{nextcompletion}":
|
||||
cy.wait(50);
|
||||
return cy.realPress([metaKey, "j"]);
|
||||
|
||||
case "{prevcompletion}":
|
||||
cy.wait(50);
|
||||
return cy.realPress([metaKey, "k"]);
|
||||
|
||||
case "{{}":
|
||||
return cy.realType("{");
|
||||
}
|
||||
|
||||
if (part.startsWith("{") && part.endsWith("}")) {
|
||||
// unknown escape sequence, let's try typing it
|
||||
type("{");
|
||||
type(part.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
type(part);
|
||||
});
|
||||
|
||||
return nativeEditor();
|
||||
}
|
||||
|
||||
export const NativeEditor = {
|
||||
get: nativeEditor,
|
||||
type(text: string, options: TypeOptions = {}) {
|
||||
nativeEditorType(text, options);
|
||||
return NativeEditor;
|
||||
},
|
||||
focus() {
|
||||
focusNativeEditor();
|
||||
return NativeEditor;
|
||||
},
|
||||
blur() {
|
||||
blurNativeEditor();
|
||||
return NativeEditor;
|
||||
},
|
||||
selectAll() {
|
||||
nativeEditorSelectAll();
|
||||
return NativeEditor;
|
||||
},
|
||||
clear() {
|
||||
clearNativeEditor();
|
||||
return NativeEditor;
|
||||
},
|
||||
textbox() {
|
||||
return NativeEditor.get().get("[role='textbox']");
|
||||
},
|
||||
value() {
|
||||
// Get the multiline text content of the editor
|
||||
return NativeEditor.textbox()
|
||||
.get(".cm-line")
|
||||
.then((lines) => {
|
||||
const text: string[] = [];
|
||||
lines.each((_, line) => {
|
||||
text.push(line.textContent ?? "");
|
||||
});
|
||||
const value = text.join("\n");
|
||||
const placeholder = "SELECT * FROM TABLE_NAME";
|
||||
if (value === placeholder) {
|
||||
return "";
|
||||
}
|
||||
return value;
|
||||
});
|
||||
},
|
||||
completions: nativeEditorCompletions,
|
||||
completion: nativeEditorCompletion,
|
||||
};
|
||||
export const NativeEditor = codeMirrorHelpers("native-query-editor", {
|
||||
dataSource: nativeEditorDataSource,
|
||||
selectDataSource: selectNativeEditorDataSource,
|
||||
});
|
||||
|
||||
3
e2e/support/helpers/e2e-python-helpers.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { codeMirrorHelpers } from "./e2e-codemirror-helpers";
|
||||
|
||||
export const PythonEditor = codeMirrorHelpers("python-editor", {});
|
||||
@@ -6,6 +6,7 @@ export * from "./e2e-bi-basics-helpers";
|
||||
export * from "./e2e-boolean-helpers";
|
||||
export * from "./e2e-browser-helpers";
|
||||
export * from "./e2e-cloud-helpers";
|
||||
export * from "./e2e-codemirror-helpers";
|
||||
export * from "./e2e-collection-helpers";
|
||||
export * from "./e2e-command-palette-helpers";
|
||||
export * from "./e2e-comment-helpers";
|
||||
@@ -35,6 +36,7 @@ export * from "./e2e-native-editor-helpers";
|
||||
export * from "./e2e-notebook-helpers";
|
||||
export * from "./e2e-notification-helpers";
|
||||
export * from "./e2e-permissions-helpers";
|
||||
export * from "./e2e-python-helpers";
|
||||
export * from "./e2e-qa-databases-helpers";
|
||||
export * from "./e2e-relative-date-picker-helpers";
|
||||
export * from "./e2e-request-helpers";
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("scenarios > embedding-sdk > incompatibility-with-instance-banner", ()
|
||||
mockAuthProviderAndJwtSignIn();
|
||||
});
|
||||
|
||||
describe("when the SDK is incompatible with an instance", () => {
|
||||
describe("when the SDK bundle is incompatible with an instance", () => {
|
||||
it("should show an error with a close button", () => {
|
||||
cy.mount(
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
InteractiveDashboard,
|
||||
InteractiveQuestion,
|
||||
MetabaseProvider,
|
||||
type MetabaseTheme,
|
||||
StaticQuestion,
|
||||
defineMetabaseTheme,
|
||||
} from "@metabase/embedding-sdk-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
@@ -132,6 +134,42 @@ describe("scenarios > embedding-sdk > styles", () => {
|
||||
.invoke("css", "color")
|
||||
.should("equal", "rgb(255, 0, 0)");
|
||||
});
|
||||
|
||||
it('should be able to reset theme colors by setting it to "undefined" (EMB-696)', () => {
|
||||
const THEME = defineMetabaseTheme({
|
||||
colors: {
|
||||
"text-primary": "#0000ff",
|
||||
},
|
||||
});
|
||||
function TestComponent() {
|
||||
const [theme, setTheme] = useState<MetabaseTheme | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return (
|
||||
<MetabaseProvider
|
||||
authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}
|
||||
theme={theme}
|
||||
>
|
||||
<button onClick={() => setTheme(THEME)}>Set theme</button>
|
||||
<button onClick={() => setTheme(undefined)}>Remove theme</button>
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>
|
||||
);
|
||||
}
|
||||
cy.mount(<TestComponent />);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByRole("button", { name: "Set theme" }).click();
|
||||
cy.findByText("Orders")
|
||||
.invoke("css", "color")
|
||||
.should("equal", "rgb(0, 0, 255)");
|
||||
cy.findByRole("button", { name: "Remove theme" }).click();
|
||||
cy.findByText("Orders")
|
||||
.invoke("css", "color")
|
||||
// --mb-color-text-primary
|
||||
.should("equal", "rgb(76, 87, 115)");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("style leaking", () => {
|
||||
|
||||
@@ -122,9 +122,9 @@ describe("admin > database > add", () => {
|
||||
.click({ force: true })
|
||||
.should("have.attr", "data-checked", "true");
|
||||
|
||||
cy.findByLabelText(
|
||||
cy.findByDisplayValue(
|
||||
"Never, I'll do this manually if I need to",
|
||||
).should("have.attr", "aria-selected", "true");
|
||||
).should("exist");
|
||||
|
||||
// make sure tooltips behave as expected
|
||||
cy.findByLabelText("Host")
|
||||
@@ -243,11 +243,9 @@ describe("admin > database > add", () => {
|
||||
"true",
|
||||
);
|
||||
|
||||
cy.findByLabelText("Never, I'll do this manually if I need to").should(
|
||||
"have.attr",
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
cy.findByDisplayValue(
|
||||
"Never, I'll do this manually if I need to",
|
||||
).should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -711,17 +709,15 @@ describe("scenarios > admin > databases > sample database", () => {
|
||||
});
|
||||
|
||||
// "lets you change the cache_field_values period"
|
||||
cy.findByLabelText("Never, I'll do this manually if I need to").should(
|
||||
"have.attr",
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
cy.findByDisplayValue("Never, I'll do this manually if I need to")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.findByLabelText("Regularly, on a schedule")
|
||||
.click()
|
||||
.within(() => {
|
||||
cy.findByText("Daily").click();
|
||||
});
|
||||
H.popover().findByText("Regularly, on a schedule").click();
|
||||
cy.findAllByRole("button", { name: /Daily/ })
|
||||
.should("have.length", 2)
|
||||
.eq(1)
|
||||
.click();
|
||||
H.popover().findByText("Weekly").click();
|
||||
|
||||
cy.button("Save changes").click();
|
||||
@@ -735,7 +731,8 @@ describe("scenarios > admin > databases > sample database", () => {
|
||||
});
|
||||
|
||||
// "lets you change the cache_field_values to 'Only when adding a new filter widget'"
|
||||
cy.findByLabelText("Only when adding a new filter widget").click();
|
||||
cy.findByDisplayValue("Regularly, on a schedule").click();
|
||||
H.popover().findByText("Only when adding a new filter widget").click();
|
||||
cy.button("Save changes", { timeout: 10000 }).click();
|
||||
cy.wait("@databaseUpdate").then(({ response: { body } }) => {
|
||||
editDatabase();
|
||||
@@ -744,7 +741,8 @@ describe("scenarios > admin > databases > sample database", () => {
|
||||
});
|
||||
|
||||
// and back to never
|
||||
cy.findByLabelText("Never, I'll do this manually if I need to").click();
|
||||
cy.findByDisplayValue("Only when adding a new filter widget").click();
|
||||
H.popover().findByText("Never, I'll do this manually if I need to").click();
|
||||
cy.button("Save changes", { timeout: 10000 }).click();
|
||||
cy.wait("@databaseUpdate").then(({ response: { body } }) => {
|
||||
editDatabase();
|
||||
@@ -769,7 +767,8 @@ describe("scenarios > admin > databases > sample database", () => {
|
||||
});
|
||||
|
||||
editDatabase();
|
||||
cy.findByLabelText("Regularly, on a schedule").click();
|
||||
cy.findByDisplayValue("Never, I'll do this manually if I need to").click();
|
||||
H.popover().findByText("Regularly, on a schedule").click();
|
||||
cy.button("Save changes").click();
|
||||
cy.wait("@databaseUpdate").then(({ request: { body } }) => {
|
||||
expect(body.is_full_sync).to.equal(true);
|
||||
@@ -783,7 +782,8 @@ describe("scenarios > admin > databases > sample database", () => {
|
||||
});
|
||||
|
||||
editDatabase();
|
||||
cy.findByLabelText("Only when adding a new filter widget").click();
|
||||
cy.findByDisplayValue("Regularly, on a schedule").click();
|
||||
H.popover().findByText("Only when adding a new filter widget").click();
|
||||
cy.button("Save changes").click();
|
||||
cy.wait("@databaseUpdate").then(({ request: { body } }) => {
|
||||
expect(body.is_full_sync).to.equal(false);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const { H } = cy;
|
||||
|
||||
import dedent from "ts-dedent";
|
||||
|
||||
import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import type {
|
||||
CardType,
|
||||
ListTransformRunsResponse,
|
||||
PythonTransformTableAliases,
|
||||
TransformTagId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
@@ -127,7 +130,7 @@ H.describeWithSnowplowEE("scenarios > admin > transforms", () => {
|
||||
it("should be able to use the data reference and snippets when writing a SQL transform", () => {
|
||||
H.createSnippet({
|
||||
name: "snippet1",
|
||||
content: "1",
|
||||
content: "'foo'",
|
||||
});
|
||||
|
||||
visitTransformListPage();
|
||||
@@ -175,12 +178,135 @@ H.describeWithSnowplowEE("scenarios > admin > transforms", () => {
|
||||
.click();
|
||||
|
||||
editorSidebar().should("not.exist");
|
||||
|
||||
cy.findByTestId("native-query-editor-action-buttons")
|
||||
.findByLabelText("Preview the query")
|
||||
.click();
|
||||
|
||||
H.modal().findByText("'foo'").should("be.visible");
|
||||
}
|
||||
|
||||
testDataReference();
|
||||
testSnippets();
|
||||
});
|
||||
|
||||
it(
|
||||
"should be possible to create and run a Python transform",
|
||||
{ tags: ["@transforms-python"] },
|
||||
() => {
|
||||
setPythonRunnerSettings();
|
||||
cy.log("create a new transform");
|
||||
visitTransformListPage();
|
||||
getTransformListPage().button("Create a transform").click();
|
||||
H.popover().findByText("Python script").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "transform_create",
|
||||
event_detail: "python",
|
||||
triggered_from: "transform-page-create-menu",
|
||||
});
|
||||
|
||||
cy.findByTestId("python-data-picker")
|
||||
.findByText("Select a database")
|
||||
.click();
|
||||
|
||||
cy.log("Unsupported databases should be disabled");
|
||||
H.popover()
|
||||
.findByRole("option", { name: "Sample Database" })
|
||||
.should("have.attr", "aria-disabled", "true");
|
||||
|
||||
cy.log("Select database");
|
||||
H.popover().findByText(DB_NAME).click();
|
||||
|
||||
getPythonDataPicker().button("Select a table…").click();
|
||||
H.entityPickerModal().findByText("Animals").click();
|
||||
|
||||
getPythonDataPicker().within(() => {
|
||||
cy.findByText("Writable Postgres12 / Schema A").should("be.visible");
|
||||
cy.findByText("Animals").should("be.visible");
|
||||
cy.findByPlaceholderText("Enter alias").should(
|
||||
"have.value",
|
||||
"animals",
|
||||
);
|
||||
cy.findByPlaceholderText("Enter alias")
|
||||
.clear()
|
||||
.type("foo bar")
|
||||
.blur();
|
||||
cy.findByPlaceholderText("Enter alias").should(
|
||||
"have.value",
|
||||
"foo_bar",
|
||||
);
|
||||
});
|
||||
|
||||
H.PythonEditor.value()
|
||||
.should("contain", "def transform(foo_bar):")
|
||||
.should(
|
||||
"contain",
|
||||
'foo_bar: DataFrame containing the data from the "Writable Postgres12.Schema A.Animals" table',
|
||||
);
|
||||
|
||||
getPythonDataPicker().within(() => {
|
||||
cy.findByText("Add a table").click();
|
||||
cy.button("Select a table…").click();
|
||||
});
|
||||
|
||||
H.entityPickerModal().within(() => {
|
||||
cy.log("Selecting the same table should not be possible");
|
||||
cy.findByText("Animals")
|
||||
.parent()
|
||||
.parent()
|
||||
.parent()
|
||||
.should("have.attr", "data-disabled", "true");
|
||||
|
||||
cy.findByText("Schema B").click();
|
||||
cy.findByText("Animals").click();
|
||||
});
|
||||
|
||||
getPythonDataPicker().within(() => {
|
||||
cy.icon("refresh").click();
|
||||
cy.findAllByPlaceholderText("Enter alias")
|
||||
.first()
|
||||
.should("have.value", "animals_1")
|
||||
.clear()
|
||||
.type("foo bar")
|
||||
.blur()
|
||||
.should("have.value", "foo_bar");
|
||||
cy.findAllByPlaceholderText("Enter alias")
|
||||
.eq(1)
|
||||
.should("have.value", "animals")
|
||||
.clear()
|
||||
.type("foo bar")
|
||||
.blur()
|
||||
.should("have.value", "foo_bar_1");
|
||||
});
|
||||
|
||||
getQueryEditor().button("Save").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").clear().type("Python transform");
|
||||
cy.findByLabelText("Table name").clear().type("python_transform");
|
||||
cy.button("Save").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "transform_created",
|
||||
});
|
||||
|
||||
cy.log("run the transform and make sure its table can be queried");
|
||||
runTransformAndWaitForSuccess();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "transform_trigger_manual_run",
|
||||
triggered_from: "transform-page",
|
||||
});
|
||||
|
||||
getRunSection().should("contain", "Executing Python transform");
|
||||
|
||||
getTableLink().click();
|
||||
H.queryBuilderHeader().findByText(DB_NAME).should("be.visible");
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("should be able to create and run a transform from a question or a model", () => {
|
||||
function testCardSource({
|
||||
type,
|
||||
@@ -511,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 });
|
||||
@@ -566,7 +800,6 @@ LIMIT
|
||||
getTagsInput().type("New tag");
|
||||
H.popover().findByText("New tag").click();
|
||||
cy.wait("@createTag");
|
||||
H.popover().findByText("New tag").should("be.visible");
|
||||
H.undoToast().should("contain.text", "Transform tags updated");
|
||||
});
|
||||
|
||||
@@ -622,7 +855,6 @@ LIMIT
|
||||
getTagsInput().type("New tag");
|
||||
H.popover().findByText("New tag").click();
|
||||
cy.wait("@createTag");
|
||||
H.popover().findByText("New tag").should("be.visible");
|
||||
|
||||
cy.log("Navigate to transform B");
|
||||
getNavSidebar().findByText("Transforms").click();
|
||||
@@ -992,6 +1224,7 @@ LIMIT
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name: TARGET_TABLE,
|
||||
schema: TARGET_SCHEMA,
|
||||
},
|
||||
@@ -1105,6 +1338,7 @@ LIMIT
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name: TARGET_TABLE,
|
||||
schema: TARGET_SCHEMA,
|
||||
},
|
||||
@@ -1165,6 +1399,38 @@ LIMIT
|
||||
H.queryBuilderHeader().findByText(DB_NAME).should("be.visible");
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
});
|
||||
|
||||
it("should be able to update a Python query", () => {
|
||||
setPythonRunnerSettings();
|
||||
cy.log("create a new transform");
|
||||
H.getTableId({ name: "Animals", databaseId: WRITABLE_DB_ID }).then(
|
||||
(id) => {
|
||||
createPythonTransform({
|
||||
body: dedent`
|
||||
import pandas as pd
|
||||
|
||||
def transform(foo):
|
||||
return pd.DataFrame([{"foo": 42 }])
|
||||
`,
|
||||
sourceTables: { foo: id },
|
||||
visitTransform: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cy.log("update the query");
|
||||
getTransformPage().findByRole("link", { name: "Edit script" }).click();
|
||||
H.PythonEditor.type("{backspace}{backspace}{backspace} + 10 }])");
|
||||
|
||||
getQueryEditor().button("Save changes").click();
|
||||
cy.wait("@updateTransform");
|
||||
|
||||
cy.log("run the transform and make sure the query has changed");
|
||||
runTransformAndWaitForSuccess();
|
||||
getTableLink().click();
|
||||
H.queryBuilderHeader().findByText(DB_NAME).should("be.visible");
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs", () => {
|
||||
@@ -1281,6 +1547,7 @@ LIMIT
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name: TARGET_TABLE,
|
||||
schema: TARGET_SCHEMA,
|
||||
},
|
||||
@@ -1347,7 +1614,7 @@ LIMIT
|
||||
"contain",
|
||||
"Last ran a few seconds ago successfully.",
|
||||
);
|
||||
getRunStatus().should(
|
||||
getRunSection().should(
|
||||
"contain",
|
||||
"This run succeeded before it had a chance to cancel.",
|
||||
);
|
||||
@@ -1391,6 +1658,136 @@ LIMIT
|
||||
H.main().findByText("Dependencies").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("python > common library", () => {
|
||||
it(
|
||||
"should be possible to edit and save the common library",
|
||||
{ tags: ["@transforms-python"] },
|
||||
() => {
|
||||
visitCommonLibrary();
|
||||
|
||||
cy.log("updating the library should be possible");
|
||||
H.PythonEditor.clear().type(
|
||||
dedent`
|
||||
def useful_calculation(a, b):
|
||||
return a + b
|
||||
`,
|
||||
);
|
||||
getLibraryEditorHeader().findByText("Save").click();
|
||||
|
||||
cy.log("the contents should be saved properly");
|
||||
visitCommonLibrary();
|
||||
H.PythonEditor.value().should(
|
||||
"eq",
|
||||
dedent`
|
||||
def useful_calculation(a, b):
|
||||
return a + b
|
||||
`,
|
||||
);
|
||||
|
||||
cy.log("reverting the changes should be possible");
|
||||
H.PythonEditor.clear().type("# oops");
|
||||
getLibraryEditorHeader().findByText("Revert").click();
|
||||
H.PythonEditor.value().should(
|
||||
"eq",
|
||||
dedent`
|
||||
def useful_calculation(a, b):
|
||||
return a + b
|
||||
`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should be possible to use the common library",
|
||||
{ tags: ["@transforms-python"] },
|
||||
() => {
|
||||
setPythonRunnerSettings();
|
||||
createPythonLibrary(
|
||||
"common.py",
|
||||
dedent`
|
||||
def useful_calculation(a, b):
|
||||
return a + b
|
||||
`,
|
||||
);
|
||||
|
||||
visitTransformListPage();
|
||||
getTransformListPage().button("Create a transform").click();
|
||||
H.popover().findByText("Python script").click();
|
||||
|
||||
H.PythonEditor.clear().type(
|
||||
dedent`
|
||||
import pandas as pd
|
||||
|
||||
def transform():
|
||||
return pd.DataFrame([{"foo": common.useful_calculation(1, 2)}])
|
||||
`,
|
||||
);
|
||||
|
||||
getQueryEditor().findByText("Import common library").click();
|
||||
H.PythonEditor.value().should("contain", "import common");
|
||||
|
||||
cy.findByTestId("python-data-picker")
|
||||
.findByText("Select a database")
|
||||
.click();
|
||||
|
||||
H.popover().findByText(DB_NAME).click();
|
||||
|
||||
cy.findByTestId("python-data-picker")
|
||||
.findByText("Select a table…")
|
||||
.click();
|
||||
|
||||
H.entityPickerModal().within(() => {
|
||||
cy.findByText("Schema a").click();
|
||||
cy.findByText("Animals").click();
|
||||
});
|
||||
|
||||
getQueryEditor().button("Save").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").clear().type("Python transform");
|
||||
cy.findByLabelText("Table name").clear().type("python_transform");
|
||||
cy.button("Save").click();
|
||||
});
|
||||
|
||||
runTransformAndWaitForSuccess();
|
||||
getTableLink().click();
|
||||
H.queryBuilderHeader()
|
||||
.findByText("Python Transform")
|
||||
.should("be.visible");
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
cy.findByTestId("scalar-value").should("have.text", "3");
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "transform_created",
|
||||
});
|
||||
|
||||
cy.log("update the common library and run the transform again");
|
||||
cy.go("back");
|
||||
createPythonLibrary(
|
||||
"common.py",
|
||||
dedent`
|
||||
def useful_calculation(a, b):
|
||||
return a + b + 40
|
||||
`,
|
||||
);
|
||||
runTransformAndWaitForSuccess();
|
||||
getTableLink().click();
|
||||
H.queryBuilderHeader()
|
||||
.findByText("Python Transform")
|
||||
.should("be.visible");
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
cy.findByTestId("scalar-value").should("have.text", "43");
|
||||
},
|
||||
);
|
||||
|
||||
function visitCommonLibrary(path = "common.py") {
|
||||
cy.visit(`/admin/transforms/library/${path}`);
|
||||
}
|
||||
|
||||
function getLibraryEditorHeader() {
|
||||
return cy.findByTestId("library-editor-header");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("scenarios > admin > transforms > databases without :schemas", () => {
|
||||
@@ -1628,6 +2025,11 @@ describe("scenarios > admin > transforms > jobs", () => {
|
||||
event: "transform_job_trigger_manual_run",
|
||||
triggered_from: "job-page",
|
||||
});
|
||||
|
||||
getJobPage()
|
||||
.findByText("Last ran a few seconds ago successfully.")
|
||||
.should("be.visible");
|
||||
|
||||
getNavSidebar().findByText("Runs").click();
|
||||
getContentTable().within(() => {
|
||||
cy.findByText("MBQL transform").should("be.visible");
|
||||
@@ -1635,6 +2037,23 @@ describe("scenarios > admin > transforms > jobs", () => {
|
||||
cy.findByText("Manual").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should display the error message from a failed run", () => {
|
||||
H.createTransformTag({ name: "New tag" }).then(({ body: tag }) => {
|
||||
createSqlTransform({
|
||||
sourceQuery: "SELECT * FROM abc",
|
||||
tagIds: [tag.id],
|
||||
});
|
||||
H.createTransformJob(
|
||||
{ name: "New job", tag_ids: [tag.id] },
|
||||
{ visitTransformJob: true },
|
||||
);
|
||||
});
|
||||
runJobAndWaitForFailure();
|
||||
getJobPage().findByText("Last run failed a few seconds ago.");
|
||||
getRunErrorInfoButton().click();
|
||||
H.modal().should("contain.text", 'relation "abc" does not exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletion", () => {
|
||||
@@ -1689,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");
|
||||
@@ -1705,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");
|
||||
@@ -1721,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");
|
||||
@@ -1731,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");
|
||||
@@ -1739,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");
|
||||
@@ -1819,8 +2271,9 @@ describe("scenarios > admin > transforms > jobs", () => {
|
||||
});
|
||||
}
|
||||
|
||||
testLastRunFilter();
|
||||
testNextRunFilter();
|
||||
testLastRunDateFilter();
|
||||
testLastRunStatusFilter();
|
||||
testNextRunDateFilter();
|
||||
testTagFilter();
|
||||
});
|
||||
});
|
||||
@@ -2245,6 +2698,10 @@ function getRunStatus() {
|
||||
return cy.findByTestId("run-status");
|
||||
}
|
||||
|
||||
function getRunSection() {
|
||||
return cy.findByTestId("run-section");
|
||||
}
|
||||
|
||||
function getRunListLink() {
|
||||
return cy.findByRole("link", { name: "See all runs" });
|
||||
}
|
||||
@@ -2305,12 +2762,16 @@ function getTransformFilterWidget() {
|
||||
return cy.findByRole("group", { name: "Transform" });
|
||||
}
|
||||
|
||||
function getLastRunAtFilterWidget() {
|
||||
function getLastRunDateFilterWidget() {
|
||||
return cy.findByRole("group", { name: "Last run at" });
|
||||
}
|
||||
|
||||
function getNextRunFilterWidget() {
|
||||
return cy.findByRole("group", { name: "Next run" });
|
||||
function getLastRunStatusFilterWidget() {
|
||||
return cy.findByRole("group", { name: "Last run status" });
|
||||
}
|
||||
|
||||
function getNextRunDateFilterWidget() {
|
||||
return cy.findByRole("group", { name: "Next run at" });
|
||||
}
|
||||
|
||||
function getStatusFilterWidget() {
|
||||
@@ -2326,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() {
|
||||
@@ -2361,6 +2822,11 @@ function runJobAndWaitForSuccess() {
|
||||
getRunButton().should("have.text", "Ran successfully");
|
||||
}
|
||||
|
||||
function runJobAndWaitForFailure() {
|
||||
getRunButton().click();
|
||||
getRunButton().should("have.text", "Run failed");
|
||||
}
|
||||
|
||||
function createMbqlTransform({
|
||||
sourceTable = SOURCE_TABLE,
|
||||
targetTable = TARGET_TABLE,
|
||||
@@ -2395,6 +2861,7 @@ function createMbqlTransform({
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name: targetTable,
|
||||
schema: targetSchema,
|
||||
},
|
||||
@@ -2433,6 +2900,42 @@ function createSqlTransform({
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name: targetTable,
|
||||
schema: targetSchema,
|
||||
},
|
||||
tag_ids: tagIds,
|
||||
},
|
||||
{ wrapId: true, visitTransform },
|
||||
);
|
||||
}
|
||||
function createPythonTransform({
|
||||
body,
|
||||
sourceTables,
|
||||
targetTable = TARGET_TABLE,
|
||||
targetSchema = TARGET_SCHEMA,
|
||||
tagIds,
|
||||
visitTransform,
|
||||
}: {
|
||||
body: string;
|
||||
sourceTables: PythonTransformTableAliases;
|
||||
targetTable?: string;
|
||||
targetSchema?: string;
|
||||
tagIds?: TransformTagId[];
|
||||
visitTransform?: boolean;
|
||||
}) {
|
||||
H.createTransform(
|
||||
{
|
||||
name: "Python transform",
|
||||
source: {
|
||||
type: "python",
|
||||
"source-database": WRITABLE_DB_ID,
|
||||
"source-tables": sourceTables,
|
||||
body,
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name: targetTable,
|
||||
schema: targetSchema,
|
||||
},
|
||||
@@ -2496,3 +2999,35 @@ function assertOptionNotSelected(name: string) {
|
||||
function editorSidebar() {
|
||||
return cy.findByTestId("editor-sidebar");
|
||||
}
|
||||
|
||||
function getPythonDataPicker() {
|
||||
return cy.findByTestId("python-data-picker");
|
||||
}
|
||||
|
||||
function createPythonLibrary(path: string, source: string) {
|
||||
cy.request("PUT", `/api/ee/transforms-python/library/${path}`, {
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
function setPythonRunnerSettings() {
|
||||
H.updateEnterpriseSetting("python-runner-url", "http://localhost:5001");
|
||||
H.updateEnterpriseSetting("python-runner-api-token", "dev-token-12345");
|
||||
H.updateEnterpriseSetting(
|
||||
"python-storage-s-3-endpoint",
|
||||
"http://localhost:4566",
|
||||
);
|
||||
H.updateEnterpriseSetting("python-storage-s-3-region", "us-east-1");
|
||||
H.updateEnterpriseSetting(
|
||||
"python-storage-s-3-bucket",
|
||||
"metabase-python-runner",
|
||||
);
|
||||
H.updateEnterpriseSetting("python-storage-s-3-prefix", "test-prefix");
|
||||
H.updateEnterpriseSetting("python-storage-s-3-access-key", "test");
|
||||
H.updateEnterpriseSetting("python-storage-s-3-secret-key", "test");
|
||||
H.updateEnterpriseSetting(
|
||||
"python-storage-s-3-container-endpoint",
|
||||
"http://localstack:4566",
|
||||
);
|
||||
H.updateEnterpriseSetting("python-storage-s-3-path-style-access", true);
|
||||
}
|
||||
|
||||
@@ -553,7 +553,10 @@ describe("scenarios > dashboard > dashboard drill", () => {
|
||||
cy.wait("@dataset").then((xhr) => {
|
||||
expect(xhr.response.body.error).not.to.exist;
|
||||
});
|
||||
cy.findByTestId("object-detail").findByText("Fantastic Wool Shirt");
|
||||
cy.findByTestId("object-detail")
|
||||
.findAllByText("Fantastic Wool Shirt")
|
||||
.should("have.length", 2)
|
||||
.and("be.visible");
|
||||
});
|
||||
|
||||
it("should apply correct date range on a graph drill-through (metabase#13785)", () => {
|
||||
|
||||
@@ -645,6 +645,33 @@ describe("scenarios > dashboard", () => {
|
||||
H.getDashboardCard(1).contains("bottom");
|
||||
},
|
||||
);
|
||||
|
||||
it("should not save the dashboard when the user clicks 'Discard changes'", () => {
|
||||
// Navigate to the dashboard via client-side navigation (to trigger the client-side "Discard changes" prompt)
|
||||
cy.visit("/");
|
||||
cy.findByTestId("main-navbar-root").findByText("Our analytics").click();
|
||||
cy.findByTestId("collection-table")
|
||||
.findByText(originalDashboardName)
|
||||
.click();
|
||||
|
||||
cy.log("Make a change to the dashboard");
|
||||
H.editDashboard();
|
||||
cy.findByTestId("dashboard-empty-state")
|
||||
.findByText("Add a chart")
|
||||
.click();
|
||||
H.sidebar().findByText("Orders, Count").click();
|
||||
H.getDashboardCards().should("have.length", 1);
|
||||
|
||||
cy.log("Navigate back and discard changes");
|
||||
cy.go("back");
|
||||
H.modal().button("Discard changes").click();
|
||||
cy.findByTestId("collection-table")
|
||||
.findByText(originalDashboardName)
|
||||
.click();
|
||||
|
||||
cy.log("Verify changes were not saved");
|
||||
cy.findByTestId("dashboard-empty-state").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("iframe cards", () => {
|
||||
|
||||
@@ -44,7 +44,9 @@ describe("detail view", () => {
|
||||
});
|
||||
|
||||
DetailView.verifyDetails([
|
||||
["ID", "1"],
|
||||
["Ean", "1018947080336"],
|
||||
["Title", "Rustic Paper Wallet"],
|
||||
["Category", "Gizmo"],
|
||||
["Vendor", "Swaniawski, Casper and Hilll"],
|
||||
["Price", "29.46"],
|
||||
@@ -122,6 +124,7 @@ describe("detail view", () => {
|
||||
});
|
||||
|
||||
DetailView.verifyDetails([
|
||||
["ID", "1"],
|
||||
["User ID", "1"],
|
||||
["Product ID", "14"],
|
||||
["Subtotal", "37.65"],
|
||||
@@ -137,6 +140,7 @@ describe("detail view", () => {
|
||||
["Products → Ean", "8833419218504"],
|
||||
["Products → Price", "25.1"],
|
||||
["Products → Rating", "4"],
|
||||
["Products → Title", "Awesome Concrete Shoes"],
|
||||
["Products → Vendor", "McClure-Lockman"],
|
||||
]);
|
||||
|
||||
@@ -273,6 +277,7 @@ describe("detail view", () => {
|
||||
});
|
||||
|
||||
DetailView.verifyDetails([
|
||||
["ID", "1"],
|
||||
["User", "Hudson Borer"],
|
||||
["Product", "Product: Awesome Concrete Shoes"],
|
||||
["Subtotal ($)", "37.65"],
|
||||
@@ -281,17 +286,19 @@ describe("detail view", () => {
|
||||
["Discount ($)", "empty"],
|
||||
["Created At", "February 11, 2025, 9:40 PM"],
|
||||
["Quantity", "two"],
|
||||
["Order image", "https://example.com/order/1.jpg"],
|
||||
["Product image", "https://example.com/product/14.jpg"],
|
||||
["Products → Category", "Widget"],
|
||||
["Products → Created At", "December 31, 2023, 2:41 PM"],
|
||||
["Products → Ean", "8833419218504"],
|
||||
["Products → Price", "25.1"],
|
||||
["Products → Rating", "4"],
|
||||
["Products → Title", "Awesome Concrete Shoes"],
|
||||
["Products → Vendor", "McClure-Lockman"],
|
||||
]);
|
||||
|
||||
cy.log("user id remapped to user name");
|
||||
DetailView.getDetailsRowValue({ index: 0, rowsCount: 15 }).within(() => {
|
||||
DetailView.getDetailsRowValue({ index: 1, rowsCount: 18 }).within(() => {
|
||||
cy.findByRole("link", { name: "Hudson Borer" })
|
||||
.should("be.visible")
|
||||
.and("have.attr", "href", `/table/${PEOPLE_ID}/detail/1`);
|
||||
@@ -300,7 +307,7 @@ describe("detail view", () => {
|
||||
cy.log(
|
||||
"product id remapped to product title, and custom view_as setting",
|
||||
);
|
||||
DetailView.getDetailsRowValue({ index: 1, rowsCount: 15 }).within(() => {
|
||||
DetailView.getDetailsRowValue({ index: 2, rowsCount: 18 }).within(() => {
|
||||
cy.findByRole("link", { name: "Product: Awesome Concrete Shoes" })
|
||||
.should("be.visible")
|
||||
.and("have.attr", "href", "https://example.com/14")
|
||||
@@ -314,13 +321,13 @@ describe("detail view", () => {
|
||||
});
|
||||
|
||||
cy.log("image should be rendered in a frame with a link");
|
||||
DetailView.getDetailsRowValue({ index: 8, rowsCount: 15 }).within(() => {
|
||||
DetailView.getDetailsRowValue({ index: 10, rowsCount: 18 }).within(() => {
|
||||
cy.findByRole("img")
|
||||
.should("exist") // do not wait for image to load
|
||||
.and("have.attr", "src", "https://example.com/product/14.jpg");
|
||||
|
||||
cy.findByRole("link")
|
||||
.should("be.visible")
|
||||
.should("exist")
|
||||
.and("have.text", "https://example.com/product/14.jpg")
|
||||
.and("have.attr", "href", "https://example.com/product/14.jpg")
|
||||
.and("have.attr", "target", "_blank")
|
||||
@@ -332,7 +339,7 @@ describe("detail view", () => {
|
||||
it("displays emails as links", () => {
|
||||
DetailView.visitTable(PEOPLE_ID, 1);
|
||||
|
||||
DetailView.getDetailsRowValue({ index: 1, rowsCount: 11 }).within(() => {
|
||||
DetailView.getDetailsRowValue({ index: 2, rowsCount: 13 }).within(() => {
|
||||
cy.findByRole("link", { name: "borer-hudson@yahoo.com" }).should(
|
||||
"have.attr",
|
||||
"href",
|
||||
|
||||
@@ -2,6 +2,7 @@ name: metabase-e2e
|
||||
|
||||
include:
|
||||
- ../../../snowplow/docker-compose.yml
|
||||
- ../../../python-runner/compose.yaml
|
||||
|
||||
services:
|
||||
postgres-sample:
|
||||
|
||||
593
e2e/test/scenarios/documents/card-embed-node.cy.spec.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
import {
|
||||
DOCUMENT_WITH_THREE_CARDS_AND_COLUMNS,
|
||||
DOCUMENT_WITH_TWO_CARDS,
|
||||
} from "e2e/support/document-initial-data";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("documents card embed node custom logic", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
});
|
||||
|
||||
describe("cardEmbed drag and drop", () => {
|
||||
beforeEach(() => {
|
||||
H.createDocument({
|
||||
name: "DnD Test Document",
|
||||
document: DOCUMENT_WITH_TWO_CARDS,
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
});
|
||||
|
||||
it("should create a flexContainer when dropping one cardEmbed onto another standalone cardEmbed", () => {
|
||||
// Wait for cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// Verify no flexContainer exists initially
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("not.exist");
|
||||
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders, Count");
|
||||
|
||||
// Verify flexContainer was created
|
||||
H.documentContent().find('[data-type="flexContainer"]').should("exist");
|
||||
|
||||
// Verify both cards are now inside the flexContainer
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder(["Orders", "Orders, Count"]);
|
||||
});
|
||||
|
||||
// Verify the flexContainer is wrapped in a resizeNode
|
||||
H.documentContent()
|
||||
.find('[data-type="resizeNode"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
cy.get('[data-type="flexContainer"]').should("exist");
|
||||
});
|
||||
|
||||
// Verify that originally separate cards are now side by side
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle drag and drop with proper drop side positioning", () => {
|
||||
// Wait for cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders, Count", {
|
||||
side: "right",
|
||||
});
|
||||
|
||||
// Verify flexContainer was created with correct order
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
// Orders, Count should be first (left), Orders should be second (right)
|
||||
assertFlexContainerCardsOrder(["Orders, Count", "Orders"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should prevent dropping a card onto itself", () => {
|
||||
// Wait for cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// Verify initial state - no flexContainer
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("not.exist");
|
||||
|
||||
// Attempt to drag and drop the card onto itself
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders");
|
||||
|
||||
// Verify no flexContainer was created
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("not.exist");
|
||||
|
||||
// Verify the card is still standalone
|
||||
H.getDocumentCard("Orders").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("advanced flexContainer scenarios", () => {
|
||||
beforeEach(() => {
|
||||
H.createDocument({
|
||||
name: "Advanced DnD Test Document",
|
||||
document: DOCUMENT_WITH_THREE_CARDS_AND_COLUMNS,
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
});
|
||||
|
||||
it("should allow you to resize the cards inside a flex container", () => {
|
||||
getCardWidths(["Orders", "Orders, Count"], (first, second) => {
|
||||
cy.wrap(first).as("ogWidth1");
|
||||
cy.wrap(second).as("ogWidth2");
|
||||
expect(first).to.be.closeTo(second, 3);
|
||||
});
|
||||
|
||||
H.getDocumentCard("Orders").as("ORDERS_CARD");
|
||||
H.getDocumentCard("Orders, Count").as("ORDERS_COUNT_CARD");
|
||||
|
||||
const flexContainer = H.getFlexContainerForCard("Orders");
|
||||
|
||||
const handles = H.getResizeHandlesForFlexContianer(flexContainer);
|
||||
|
||||
H.documentDoDrag(handles.eq(0), { x: 100 });
|
||||
|
||||
getCardWidths(["Orders", "Orders, Count"], (first, second) => {
|
||||
cy.get("@ogWidth1").then((_first) => {
|
||||
cy.get("@ogWidth2").then((_second) => {
|
||||
cy.log("compare that changes are close to the drag distance");
|
||||
expect((_first as unknown as number) + 100).to.be.closeTo(first, 3);
|
||||
expect((_second as unknown as number) - 100).to.be.closeTo(
|
||||
second,
|
||||
3,
|
||||
);
|
||||
|
||||
expect(first).to.be.closeTo(second + 200, 3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should add a third card to an existing flexContainer with 2 cards", () => {
|
||||
// Wait for all cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count, Grouped by Created At (year)")
|
||||
.should("be.visible")
|
||||
.findByTestId("chart-container")
|
||||
.should("exist");
|
||||
|
||||
// Verify initial state - flexContainer exists with 2 cards
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 2);
|
||||
});
|
||||
|
||||
// Drag the standalone card (Orders by Year) onto one of the cards in the flexContainer
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
{ side: "left" },
|
||||
);
|
||||
|
||||
// Verify the flexContainer now has 3 cards
|
||||
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder([
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
"Orders, Count",
|
||||
]);
|
||||
});
|
||||
|
||||
H.documentUndo();
|
||||
|
||||
// Drag the standalone card (Orders by Year) onto one of the cards in the flexContainer
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder([
|
||||
"Orders",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders, Count",
|
||||
]);
|
||||
});
|
||||
|
||||
H.documentUndo();
|
||||
|
||||
// Drag the standalone card (Orders by Year) onto one of the cards in the flexContainer
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders, Count",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder([
|
||||
"Orders",
|
||||
"Orders, Count",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
]);
|
||||
});
|
||||
|
||||
cy.log("changing the widths of 2 cards should leave the 3rd alone");
|
||||
|
||||
const cardNames = [
|
||||
"Orders",
|
||||
"Orders, Count",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
];
|
||||
|
||||
getCardWidths(cardNames, (first, second, third) => {
|
||||
cy.wrap(first).as("_first");
|
||||
cy.wrap(second).as("_second");
|
||||
cy.wrap(third).as("_third");
|
||||
expect(first).to.be.closeTo(second, 10);
|
||||
expect(first).to.be.closeTo(third as unknown as number, 10);
|
||||
});
|
||||
|
||||
const flexContainer = H.getFlexContainerForCard("Orders");
|
||||
|
||||
const handles = H.getResizeHandlesForFlexContianer(flexContainer);
|
||||
|
||||
H.documentDoDrag(handles.eq(1), { x: 50 });
|
||||
|
||||
getCardWidths(cardNames, (first, second, third) => {
|
||||
cy.get("@_first").then((_first) => {
|
||||
cy.get("@_second").then((_second) => {
|
||||
cy.get("@_third").then((_third) => {
|
||||
expect(first).to.be.closeTo(_first as unknown as number, 3);
|
||||
expect(second).to.be.greaterThan(_second as unknown as number);
|
||||
expect(third).to.be.lessThan(_third as unknown as number);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should prevent adding a fourth card to a flexContainer with 3 cards", () => {
|
||||
// Wait for all cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// First, add the third card to reach the limit
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
// Verify we have 3 cards in the flexContainer
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 3);
|
||||
});
|
||||
|
||||
// Add another card to try to exceed the limit
|
||||
addNewStandaloneCard("Orders Model", "model");
|
||||
|
||||
// Wait for the new card to be added
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 4); // 3 in flexContainer + 1 new standalone
|
||||
|
||||
// Try to drag the new standalone card onto the flexContainer
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders, Count", {
|
||||
side: "left",
|
||||
});
|
||||
|
||||
// Verify the flexContainer still has only 3 cards (drop should be rejected)
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 3);
|
||||
});
|
||||
|
||||
// Verify the standalone card is still separate
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 4); // Still 4 total, with 1 standalone
|
||||
});
|
||||
|
||||
it("should reorder cards within the same flexContainer", () => {
|
||||
// Wait for all cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// Verify initial order: Orders | Orders, Count
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder(["Orders", "Orders, Count"]);
|
||||
});
|
||||
|
||||
// Drag Orders to the right side of Orders, Count to reorder them
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders, Count", {
|
||||
side: "right",
|
||||
});
|
||||
|
||||
// Verify new order: Orders, Count | Orders
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder(["Orders, Count", "Orders"]);
|
||||
});
|
||||
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders, Count", {
|
||||
side: "left",
|
||||
});
|
||||
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder(["Orders", "Orders, Count"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle moving cards between different flexContainers", () => {
|
||||
// First create another flexContainer by dragging the standalone card onto a new location
|
||||
// Add another standalone card first
|
||||
addNewStandaloneCard("Orders Model", "model");
|
||||
|
||||
// Wait for the new card
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 4);
|
||||
|
||||
// Create a second flexContainer by dropping Orders by Year onto the new Orders card
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders Model",
|
||||
);
|
||||
|
||||
// Verify we now have 2 flexContainers
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("have.length", 2);
|
||||
|
||||
// Move a card from the first flexContainer to the second one
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
// Verify the first flexContainer now has only 1 card (should be unwrapped)
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("have.length", 1);
|
||||
|
||||
// Verify the remaining flexContainer has 3 cards
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 3);
|
||||
});
|
||||
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("have.length", 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleting a cardEmbed", () => {
|
||||
it("should delete a cardEmbed when selected and Backspace is pressed", () => {
|
||||
H.createDocument({
|
||||
name: "DnD Test Document",
|
||||
document: DOCUMENT_WITH_TWO_CARDS,
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
|
||||
// Wait for cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// Verify initial state - we have 2 standalone cards
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 2);
|
||||
|
||||
// Click on the Orders card to select it
|
||||
H.getDocumentCard("Orders").realClick({ position: "top" });
|
||||
|
||||
// Press Backspace to delete the selected card
|
||||
cy.realPress("Backspace");
|
||||
|
||||
// Verify the Orders card has been deleted
|
||||
H.getDocumentCard("Orders", false).should("not.exist");
|
||||
|
||||
// Verify only one card remains
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 1);
|
||||
|
||||
// Verify the remaining card is Orders, Count
|
||||
H.getDocumentCard("Orders, Count").should("exist").and("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a cardEmbed from a flexContainer when selected and Backspace is pressed", () => {
|
||||
H.createDocument({
|
||||
name: "DnD Test Document",
|
||||
document: DOCUMENT_WITH_THREE_CARDS_AND_COLUMNS,
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
|
||||
// First create a flexContainer by dropping one card onto another
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// Create flexContainer
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
// Verify flexContainer was created with 2 cards
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 3);
|
||||
});
|
||||
|
||||
// Click on one of the cards in the flexContainer to select it
|
||||
H.getDocumentCard("Orders").realClick({ position: "top" });
|
||||
|
||||
// Press Backspace to delete the selected card
|
||||
cy.realPress("Backspace");
|
||||
|
||||
// Verify the Orders card has been deleted from the flexContainer
|
||||
H.getDocumentCard("Orders", false).should("not.exist");
|
||||
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 2);
|
||||
|
||||
H.documentContent().get('[data-type="flexContainer"]').should("exist");
|
||||
|
||||
// Click on one of the cards in the flexContainer to select it
|
||||
H.getDocumentCard("Orders, Count").realClick({ position: "top" });
|
||||
|
||||
// Press Backspace to delete the selected card
|
||||
cy.realPress("Backspace");
|
||||
|
||||
// Verify the flexContainer now has only 1 card and should be unwrapped back to standalone
|
||||
H.documentContent()
|
||||
.get('[data-type="flexContainer"]')
|
||||
.should("not.exist"); // FlexContainer should be unwrapped when only 1 card remains
|
||||
|
||||
// Verify only the Orders, Count card remains as a standalone card
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 1);
|
||||
H.getDocumentCard("Orders, Count, Grouped by Created At (year)").should(
|
||||
"exist",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function assertFlexContainerCardsOrder(expectedCardTitles: string[]) {
|
||||
for (let i = 0; i < expectedCardTitles.length; i++) {
|
||||
cy.findAllByTestId("document-card-embed")
|
||||
.should("have.length", expectedCardTitles.length)
|
||||
.eq(i)
|
||||
.findByTestId("card-embed-title")
|
||||
.should("contain.text", expectedCardTitles[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function addNewStandaloneCard(
|
||||
cardName: string,
|
||||
cardType: "question" | "model",
|
||||
) {
|
||||
cy.get(".node-paragraph.is-empty").click();
|
||||
H.addToDocument("/", false);
|
||||
H.commandSuggestionItem("Chart").click();
|
||||
H.entityPickerModalTab(
|
||||
cardType === "question" ? "Questions" : "Models",
|
||||
).click();
|
||||
H.entityPickerModalItem(1, cardName).click();
|
||||
}
|
||||
|
||||
function getCardWidths(
|
||||
cardNames: string[],
|
||||
cb: (val1: number, val2: number, val3?: number) => void,
|
||||
) {
|
||||
const [firstCardName, secondCardName, thirdCardName] = cardNames;
|
||||
H.getDocumentCard(firstCardName).then((firstCard) => {
|
||||
H.getDocumentCard(secondCardName).then((secondCard) => {
|
||||
if (thirdCardName) {
|
||||
H.getDocumentCard(thirdCardName).then((thirdCard) => {
|
||||
cb(
|
||||
firstCard.width() as number,
|
||||
secondCard.width() as number,
|
||||
thirdCard.width() as number,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
cb(firstCard.width() as number, secondCard.width() as number);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -50,27 +50,38 @@ H.describeWithSnowplowEE("document comments", () => {
|
||||
testCommentingOnNode(documentId, BLOCKQUOTE_ID, getBlockquote);
|
||||
testCommentingOnNode(documentId, ORDERED_LIST_ID, getOrderedList);
|
||||
testCommentingOnNode(documentId, CODE_BLOCK_ID, getCodeBlock);
|
||||
testCommentingOnNode(documentId, CARD_EMBED_ID, getEmbed);
|
||||
testCommentingOnNode(documentId, CARD_EMBED_ID, getEmbed, {
|
||||
isCardEmbedNode: true,
|
||||
});
|
||||
});
|
||||
|
||||
function testCommentingOnNode<E extends HTMLElement>(
|
||||
targetId: DocumentId,
|
||||
childTargetId: string,
|
||||
getNodeElement: () => Cypress.Chainable<JQuery<E>>,
|
||||
{ isCardEmbedNode = false } = {},
|
||||
) {
|
||||
cy.get("body").click(0, 0);
|
||||
Comments.getDocumentNodeButton({ targetId, childTargetId }).should(
|
||||
"not.be.visible",
|
||||
);
|
||||
|
||||
getNodeElement()
|
||||
.closest("[data-node-view-wrapper]")
|
||||
.should("have.attr", "aria-expanded", "false");
|
||||
getNodeElement().scrollIntoView();
|
||||
getNodeElement().realHover();
|
||||
Comments.getDocumentNodeButton({ targetId, childTargetId })
|
||||
.should("be.visible")
|
||||
.click();
|
||||
if (isCardEmbedNode) {
|
||||
getNodeElement().scrollIntoView();
|
||||
getNodeElement().icon("ellipsis").click();
|
||||
|
||||
H.menu().findByText("Comment").click();
|
||||
} else {
|
||||
Comments.getDocumentNodeButton({ targetId, childTargetId }).should(
|
||||
"not.be.visible",
|
||||
);
|
||||
|
||||
getNodeElement()
|
||||
.closest("[data-node-view-wrapper]")
|
||||
.should("have.attr", "aria-expanded", "false");
|
||||
getNodeElement().scrollIntoView();
|
||||
getNodeElement().realHover();
|
||||
Comments.getDocumentNodeButton({ targetId, childTargetId })
|
||||
.should("be.visible")
|
||||
.click();
|
||||
}
|
||||
|
||||
Comments.getSidebar().within(() => {
|
||||
cy.findByRole("heading", { name: "Comments about this" }).should(
|
||||
@@ -95,6 +106,7 @@ H.describeWithSnowplowEE("document comments", () => {
|
||||
targetId,
|
||||
childTargetId,
|
||||
hasComments: true,
|
||||
isCardEmbedNode,
|
||||
})
|
||||
.should("be.visible")
|
||||
.and("contain.text", "1");
|
||||
@@ -112,6 +124,7 @@ H.describeWithSnowplowEE("document comments", () => {
|
||||
targetId,
|
||||
childTargetId,
|
||||
hasComments: true,
|
||||
isCardEmbedNode,
|
||||
}).should("be.visible");
|
||||
}
|
||||
});
|
||||
@@ -1801,12 +1814,21 @@ function createLoremIpsumDocument() {
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "cardEmbed",
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: CARD_EMBED_ID,
|
||||
height: 350,
|
||||
minHeight: 280,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: CARD_EMBED_ID,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
|
||||
@@ -193,12 +193,21 @@ H.describeWithSnowplowEE("documents", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "cardEmbed",
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "2",
|
||||
height: 442,
|
||||
minHeight: 280,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "2",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
@@ -217,12 +226,16 @@ H.describeWithSnowplowEE("documents", () => {
|
||||
|
||||
it("renders a 'not found' message if the copied card has been permanently deleted", () => {
|
||||
cy.get<Document>("@document").then(({ id, document: { content } }) => {
|
||||
const cardEmbed = content?.find((n) => n.type === "cardEmbed");
|
||||
const resizeNode = content?.find((n) => n.type === "resizeNode");
|
||||
const cardEmbed = resizeNode?.content?.[0];
|
||||
const clonedCardId = cardEmbed?.attrs?.id;
|
||||
cy.request("DELETE", `/api/card/${clonedCardId}`);
|
||||
cy.visit(`/document/${id}`);
|
||||
});
|
||||
H.getDocumentCard("Couldn't find this chart.").should("exist");
|
||||
cy.findByTestId("document-card-embed").should(
|
||||
"have.text",
|
||||
"Couldn't find this chart.",
|
||||
);
|
||||
});
|
||||
|
||||
it("read only access", () => {
|
||||
@@ -684,12 +697,48 @@ H.describeWithSnowplowEE("documents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
H.getDocumentCard(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name).should(
|
||||
"not.exist",
|
||||
);
|
||||
H.documentContent()
|
||||
.findAllByTestId("card-embed-title")
|
||||
.contains(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name)
|
||||
.should("not.exist");
|
||||
|
||||
H.getDocumentCard("Orders").should("exist");
|
||||
});
|
||||
|
||||
it("should support resizing cards", () => {
|
||||
H.documentContent().click();
|
||||
H.addToDocument("/", false);
|
||||
|
||||
cy.log("search via type");
|
||||
H.addToDocument("Accounts", false);
|
||||
H.commandSuggestionDialog().should(
|
||||
"contain.text",
|
||||
ACCOUNTS_COUNT_BY_CREATED_AT.name,
|
||||
);
|
||||
|
||||
cy.realPress("{downarrow}");
|
||||
H.addToDocument("\n", false);
|
||||
|
||||
H.getDocumentCard(ACCOUNTS_COUNT_BY_CREATED_AT.name).then((el) => {
|
||||
const ogHeight = el.height();
|
||||
const resizeNode = H.getDocumentCardResizeContainer(
|
||||
ACCOUNTS_COUNT_BY_CREATED_AT.name,
|
||||
);
|
||||
|
||||
H.documentDoDrag(H.getDragHandleForDocumentResizeNode(resizeNode), {
|
||||
y: 200,
|
||||
});
|
||||
|
||||
H.getDocumentCard(ACCOUNTS_COUNT_BY_CREATED_AT.name).then((el) => {
|
||||
const newHeight = el.height();
|
||||
|
||||
cy.log(`${ogHeight}, ${newHeight}`);
|
||||
|
||||
expect(newHeight).to.be.lessThan(ogHeight as number);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should copy an added card on save", () => {
|
||||
cy.intercept({
|
||||
method: "PUT",
|
||||
|
||||
@@ -287,39 +287,55 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles save button for exploration", () => {
|
||||
navigateToEmbedOptionsStep({ experience: "exploration" });
|
||||
["exploration", "chart"].forEach((experience) => {
|
||||
it(`toggles save button for ${experience}`, () => {
|
||||
navigateToEmbedOptionsStep({
|
||||
experience,
|
||||
...(experience === "chart" && { resourceName: QUESTION_NAME }),
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("Orders").click();
|
||||
cy.findByText("Visualize").click();
|
||||
if (experience === "exploration") {
|
||||
cy.log("visualize a question to enable the save button");
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("Orders").click();
|
||||
cy.findByText("Visualize").click();
|
||||
});
|
||||
}
|
||||
|
||||
getEmbedSidebar()
|
||||
.findByLabelText("Allow people to save new questions")
|
||||
.should("not.be.checked");
|
||||
|
||||
cy.log("save button should be hidden by default");
|
||||
H.getSimpleEmbedIframeContent().findByText("Save").should("not.exist");
|
||||
|
||||
cy.log("turn on save option");
|
||||
getEmbedSidebar()
|
||||
.findByLabelText("Allow people to save new questions")
|
||||
.click()
|
||||
.should("be.checked");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "isSaveEnabled",
|
||||
});
|
||||
|
||||
if (experience === "chart") {
|
||||
cy.log("select a different visualization to enable the save button");
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("Table").click();
|
||||
H.popover().findByText("Number").click();
|
||||
});
|
||||
}
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("Save").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("snippet should be updated");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
codeBlock().should("contain", 'is-save-enabled="true"');
|
||||
});
|
||||
|
||||
getEmbedSidebar()
|
||||
.findByLabelText("Allow users to save new questions")
|
||||
.should("not.be.checked");
|
||||
|
||||
cy.log("save button should be hidden by default");
|
||||
H.getSimpleEmbedIframeContent().findByText("Save").should("not.exist");
|
||||
|
||||
cy.log("turn on save option");
|
||||
getEmbedSidebar()
|
||||
.findByLabelText("Allow users to save new questions")
|
||||
.click()
|
||||
.should("be.checked");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "isSaveEnabled",
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("Save").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("snippet should be updated");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
codeBlock().should("contain", 'is-save-enabled="true"');
|
||||
});
|
||||
|
||||
it("can toggle read-only setting for browser", () => {
|
||||
|
||||
@@ -315,13 +315,18 @@ H.describeWithSnowplowEE(
|
||||
() => {
|
||||
beforeEach(() => {
|
||||
H.resetSnowplow();
|
||||
H.prepareSdkIframeEmbedTest({ signOut: false });
|
||||
H.prepareSdkIframeEmbedTest({
|
||||
enabledAuthMethods: ["jwt"],
|
||||
signOut: false,
|
||||
});
|
||||
H.enableTracking();
|
||||
cy.signOut();
|
||||
});
|
||||
|
||||
it("should send an Embedded Analytics JS usage event", () => {
|
||||
cy.signOut();
|
||||
cy.visit("http://localhost:4000");
|
||||
const frame = H.loadSdkIframeEmbedTestPage({
|
||||
origin: "http://different-than-metabase-instance.com",
|
||||
elements: [
|
||||
{
|
||||
component: "metabase-dashboard",
|
||||
@@ -405,6 +410,26 @@ H.describeWithSnowplowEE(
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not send an Embedded Analytics JS usage event in the preview", () => {
|
||||
cy.visit("/embed-js");
|
||||
|
||||
H.waitForSimpleEmbedIframesToLoad();
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("Orders in a dashboard").should("be.visible");
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: "setup",
|
||||
global: {
|
||||
auth_method: "session",
|
||||
},
|
||||
},
|
||||
// Expect that the usage event shouldn't be sent
|
||||
0,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -139,7 +139,7 @@ describe("scenarios > model indexes", () => {
|
||||
cy.findByRole("heading", { name: "Small Marble Shoes" }).should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findAllByText("Small Marble Shoes").should("have.length", 1);
|
||||
cy.findAllByText("Small Marble Shoes").should("have.length", 2);
|
||||
cy.findByText("Doohickey").should("be.visible");
|
||||
});
|
||||
});
|
||||
@@ -176,7 +176,7 @@ describe("scenarios > model indexes", () => {
|
||||
cy.findByTestId("object-detail").within(() => {
|
||||
cy.findByText(/We're a little lost/i).should("not.exist");
|
||||
cy.findByRole("heading", { name: "Anais Zieme" }).should("be.visible");
|
||||
cy.findAllByText("Anais Zieme").should("have.length", 1);
|
||||
cy.findAllByText("Anais Zieme").should("have.length", 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe("scenarios > model indexes", () => {
|
||||
cy.findByRole("heading", { name: "Small Marble Shoes" }).should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findAllByText("Small Marble Shoes").should("have.length", 1);
|
||||
cy.findAllByText("Small Marble Shoes").should("have.length", 2);
|
||||
cy.findByText("Doohickey").should("be.visible");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const { H } = cy;
|
||||
import { SAMPLE_DB_ID, USER_GROUPS } from "e2e/support/cypress_data";
|
||||
import {
|
||||
SAMPLE_DB_ID,
|
||||
USER_GROUPS,
|
||||
WRITABLE_DB_ID,
|
||||
} from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
|
||||
import * as FieldFilter from "./helpers/e2e-field-filter-helpers";
|
||||
@@ -1272,3 +1276,30 @@ describe("issue 58061", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 63537", () => {
|
||||
beforeEach(() => {
|
||||
H.restore("postgres-writable");
|
||||
H.resetTestTable({ type: "postgres", table: "many_data_types" });
|
||||
cy.signInAsAdmin();
|
||||
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: "many_data_types" });
|
||||
});
|
||||
|
||||
it("should allow to use postgres enums in field filters (metabase#63537)", () => {
|
||||
H.startNewNativeQuestion({ database: WRITABLE_DB_ID });
|
||||
H.NativeEditor.type("SELECT * FROM many_data_types WHERE {{f}}");
|
||||
SQLFilter.openTypePickerFromDefaultFilterType();
|
||||
SQLFilter.chooseType("Field Filter");
|
||||
FieldFilter.mapTo({
|
||||
table: "Many Data Types",
|
||||
field: "Enum",
|
||||
});
|
||||
H.filterWidget().click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("beta").click();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
H.runNativeQuery();
|
||||
H.assertQueryBuilderRowCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -578,8 +578,9 @@ describe("scenarios > question > native", () => {
|
||||
cy.button("Preview the query").click();
|
||||
cy.wait("@datasetNative");
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText(/where CATEGORY='Gadget'/).should("be.visible");
|
||||
H.modal().within(() => {
|
||||
H.codeMirrorValue().should("contain", "CATEGORY = 'Gadget'");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show errors when previewing a query", () => {
|
||||
|
||||
@@ -338,6 +338,31 @@ describe("scenarios > native > snippet tags", () => {
|
||||
H.assertTableRowsCount(53);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle snippet tags with trailing spaces correctly", () => {
|
||||
cy.log("create a snippet");
|
||||
H.createSnippet({
|
||||
name: "category filter",
|
||||
content: "category = {{category}}",
|
||||
});
|
||||
|
||||
H.startNewNativeQuestion();
|
||||
|
||||
cy.log("type a query with snippet tags containing trailing spaces");
|
||||
H.NativeEditor.type(
|
||||
"select id from products where {{snippet: category filter }}",
|
||||
);
|
||||
|
||||
cy.log("verify snippet reference is recognized");
|
||||
getEditorTopBar().within(() => {
|
||||
cy.findByPlaceholderText("Category").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("verify the query can be run with the parameter");
|
||||
getEditorTopBar().findByPlaceholderText("Category").type("Widget");
|
||||
H.runNativeQuery();
|
||||
H.assertQueryBuilderRowCount(54);
|
||||
});
|
||||
});
|
||||
|
||||
function getEditorSidebar() {
|
||||
|
||||
@@ -183,7 +183,9 @@ describe("scenarios > question > snippets", () => {
|
||||
cy.findByTestId("native-query-top-bar")
|
||||
.findByLabelText("Preview the query")
|
||||
.click();
|
||||
H.modal().findByText("select 'foo'").should("be.visible");
|
||||
H.modal().within(() => {
|
||||
H.codeMirrorValue().should("eq", "select\n 'foo'");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -393,6 +393,16 @@ describe("command palette", () => {
|
||||
H.activateToken("bleeding-edge");
|
||||
});
|
||||
|
||||
it("should show the 'Create a new embed' command palette item", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("button", { name: /search/ }).click();
|
||||
|
||||
H.commandPalette().within(() => {
|
||||
H.commandPaletteInput().should("exist").type("new embed");
|
||||
cy.findByText("Create a new embed").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have a 'New document' item", () => {
|
||||
cy.visit("/");
|
||||
cy.findByRole("button", { name: /search/ }).click();
|
||||
|
||||
@@ -54,6 +54,7 @@ H.describeWithSnowplow("scenarios > search > snowplow", () => {
|
||||
search_engine: P.string,
|
||||
request_id: P.string,
|
||||
entity_model: P.string,
|
||||
entity_id: P.number,
|
||||
search_term_hash: P.string,
|
||||
search_term: null,
|
||||
},
|
||||
|
||||
@@ -96,14 +96,9 @@ describe("scenarios > alert > types", { tags: "@external" }, () => {
|
||||
H.modal().findByTestId("alert-goal-select").should("be.enabled").click();
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByText("When results go above the goal line").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText("When results go below the goal line").should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
cy.findByText("When results go above the goal line").click();
|
||||
cy.findByText("When results go above the goal").should("be.visible");
|
||||
cy.findByText("When results go below the goal").should("be.visible");
|
||||
cy.findByText("When results go above the goal").click();
|
||||
});
|
||||
|
||||
H.modal().within(() => {
|
||||
|
||||
@@ -179,7 +179,7 @@ describe("embed modal display", () => {
|
||||
});
|
||||
|
||||
describe("when the user has a paid instance", () => {
|
||||
it("should display a disabled state and a link to the Interactive embedding settings", () => {
|
||||
it("should display a disabled state and a link to the Embedded Analytics JS settings", () => {
|
||||
H.activateToken("pro-self-hosted");
|
||||
H.visitDashboard("@dashboardId");
|
||||
|
||||
@@ -187,14 +187,14 @@ describe("embed modal display", () => {
|
||||
|
||||
H.getEmbedModalSharingPane().within(() => {
|
||||
cy.findByText("Static embedding").should("be.visible");
|
||||
cy.findByText("Interactive embedding").should("be.visible");
|
||||
cy.findByText("Embedded Analytics JS").should("be.visible");
|
||||
|
||||
cy.findByRole("article", { name: "Interactive embedding" }).within(
|
||||
cy.findByRole("article", { name: "Embedded Analytics JS" }).within(
|
||||
() => {
|
||||
cy.findByText("Disabled.").should("be.visible");
|
||||
cy.findByText("Enable in admin settings")
|
||||
.should("be.visible")
|
||||
.and("have.attr", "href", "/admin/embedding/interactive");
|
||||
.and("have.attr", "href", "/admin/embedding/modular");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -212,15 +212,15 @@ describe("embed modal display", () => {
|
||||
|
||||
H.getEmbedModalSharingPane().within(() => {
|
||||
cy.findByText("Static embedding").should("be.visible");
|
||||
cy.findByText("Interactive embedding").should("be.visible");
|
||||
cy.findByText("Embedded Analytics JS").should("be.visible");
|
||||
|
||||
cy.findByRole("link", { name: "Interactive embedding" }).should(
|
||||
cy.findByRole("link", { name: "Embedded Analytics JS" }).should(
|
||||
"have.attr",
|
||||
"href",
|
||||
"https://www.metabase.com/product/embedded-analytics?utm_source=product&utm_medium=upsell&utm_campaign=embedding-interactive&utm_content=static-embed-popover&source_plan=oss",
|
||||
"https://www.metabase.com/product/embedded-analytics?utm_source=product&utm_medium=upsell&utm_campaign=embedded-analytics-js&utm_content=static-embed-popover&source_plan=oss",
|
||||
);
|
||||
|
||||
cy.findByRole("article", { name: "Interactive embedding" }).within(
|
||||
cy.findByRole("article", { name: "Embedded Analytics JS" }).within(
|
||||
() => {
|
||||
cy.findByText("Learn more").should("be.visible");
|
||||
cy.findByText("Disabled.").should("not.exist");
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
ORDERS_DASHBOARD_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
import { getEmbedSidebar } from "../embedding/sdk-iframe-embedding-setup/helpers";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("embed flow pre-selection from sharing modal", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("pro-self-hosted");
|
||||
H.updateSetting("enable-embedding-simple", true);
|
||||
});
|
||||
|
||||
it("pre-selects dashboard in embed flow when opened from dashboard sharing modal", () => {
|
||||
H.visitDashboard(ORDERS_DASHBOARD_ID);
|
||||
H.openSharingMenu("Embed");
|
||||
|
||||
H.getEmbedModalSharingPane().within(() => {
|
||||
cy.findByRole("link", { name: "Embedded Analytics JS" }).click();
|
||||
});
|
||||
|
||||
cy.location("search").should((search) => {
|
||||
const params = new URLSearchParams(search);
|
||||
|
||||
expect(params.get("resource_type")).to.equal("dashboard");
|
||||
expect(params.get("resource_id")).to.equal(String(ORDERS_DASHBOARD_ID));
|
||||
});
|
||||
|
||||
cy.log("should navigate to embed options step");
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Behavior").should("be.visible");
|
||||
cy.findByText("Appearance").should("be.visible");
|
||||
});
|
||||
|
||||
H.waitForSimpleEmbedIframesToLoad();
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Orders in a dashboard", { timeout: 10_000 })
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("pre-selects question in embed flow when opened from question sharing modal", () => {
|
||||
H.visitQuestion(ORDERS_QUESTION_ID);
|
||||
H.openSharingMenu("Embed");
|
||||
|
||||
H.getEmbedModalSharingPane().within(() => {
|
||||
cy.findByRole("link", { name: "Embedded Analytics JS" }).click();
|
||||
});
|
||||
|
||||
cy.location("search").should((search) => {
|
||||
const params = new URLSearchParams(search);
|
||||
|
||||
expect(params.get("resource_type")).to.equal("question");
|
||||
expect(params.get("resource_id")).to.equal(String(ORDERS_QUESTION_ID));
|
||||
});
|
||||
|
||||
cy.log("should navigate to embed options step");
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Behavior").should("be.visible");
|
||||
cy.findByText("Appearance").should("be.visible");
|
||||
});
|
||||
|
||||
H.waitForSimpleEmbedIframesToLoad();
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Orders", { timeout: 10_000 })
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
@@ -429,28 +429,30 @@ describe("scenarios > question > object details", { tags: "@slow" }, () => {
|
||||
H.openObjectDetail(0);
|
||||
|
||||
cy.findByTestId("object-detail").within(() => {
|
||||
cy.findByText("Link to review 1")
|
||||
.should("be.visible")
|
||||
.should("have.attr", "href")
|
||||
cy.findAllByText("Link to review 1")
|
||||
.should("have.length", 2)
|
||||
.and("be.visible")
|
||||
.and("have.attr", "href")
|
||||
.and("eq", "https://metabase.test?review=1");
|
||||
|
||||
cy.findByText("Rating: 5")
|
||||
.should("be.visible")
|
||||
.should("have.attr", "href")
|
||||
.and("have.attr", "href")
|
||||
.and("eq", "https://metabase.test?rating=5");
|
||||
});
|
||||
|
||||
cy.findByLabelText("Next row").click();
|
||||
|
||||
cy.findByTestId("object-detail").within(() => {
|
||||
cy.findByText("Link to review 2")
|
||||
.should("be.visible")
|
||||
.should("have.attr", "href")
|
||||
cy.findAllByText("Link to review 2")
|
||||
.should("have.length", 2)
|
||||
.and("be.visible")
|
||||
.and("have.attr", "href")
|
||||
.and("eq", "https://metabase.test?review=2");
|
||||
|
||||
cy.findByText("Rating: 4")
|
||||
.should("be.visible")
|
||||
.should("have.attr", "href")
|
||||
.and("have.attr", "href")
|
||||
.and("eq", "https://metabase.test?rating=4");
|
||||
});
|
||||
});
|
||||
@@ -574,7 +576,7 @@ describe("scenarios > question > object details", { tags: "@slow" }, () => {
|
||||
});
|
||||
|
||||
describe("detail page links - questions", () => {
|
||||
it("no primary keys", () => {
|
||||
it("no primary keys (WRK-900)", () => {
|
||||
H.visitQuestionAdhoc({
|
||||
display: "table",
|
||||
dataset_query: {
|
||||
@@ -596,6 +598,10 @@ describe("scenarios > question > object details", { tags: "@slow" }, () => {
|
||||
cy.findByTestId("object-detail").within(() => {
|
||||
cy.findByLabelText("Copy link to this record").should("not.exist");
|
||||
cy.findByLabelText("Open in full page").should("not.exist");
|
||||
|
||||
cy.log("should not show relationships when there is no PK (WRK-900)");
|
||||
cy.findByText(/is connected to/).should("not.exist");
|
||||
cy.findByRole("link", { name: /Orders/ }).should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -673,7 +679,7 @@ describe("scenarios > question > object details", { tags: "@slow" }, () => {
|
||||
});
|
||||
|
||||
describe("detail page links - models", () => {
|
||||
it("no primary keys", () => {
|
||||
it("no primary keys (WRK-900)", () => {
|
||||
H.createQuestion(
|
||||
{
|
||||
type: "model",
|
||||
@@ -694,6 +700,10 @@ describe("scenarios > question > object details", { tags: "@slow" }, () => {
|
||||
cy.findByTestId("object-detail").within(() => {
|
||||
cy.findByLabelText("Copy link to this record").should("not.exist");
|
||||
cy.findByLabelText("Open in full page").should("not.exist");
|
||||
|
||||
cy.log("should not show relationships when there is no PK (WRK-900)");
|
||||
cy.findByText(/is connected to/).should("not.exist");
|
||||
cy.findByRole("link", { name: /Orders/ }).should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("scenarios > visualizations > table", () => {
|
||||
H.openObjectDetail(5);
|
||||
|
||||
// Ensure click on row index opens the object detail
|
||||
H.modal().findByText("6").should("be.visible");
|
||||
H.modal().findAllByText("6").should("have.length", 2).and("be.visible");
|
||||
|
||||
// Close object detail modal
|
||||
cy.realType("{esc}");
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.metabot
|
||||
"`/api/ee/metabot-v3/metabot` routes"
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.dummy-tools :as metabot-v3.dummy-tools]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.suggested-prompts :as metabot-v3.suggested-prompts]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.app-db.core :as mdb]
|
||||
[metabase.collections.models.collection :as collection]
|
||||
[metabase.lib-be.metadata.jvm :as lib.metadata.jvm]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;; TODO: Eventually this should be paged but since we are just going to hardcode two models for now
|
||||
@@ -28,135 +27,30 @@
|
||||
(api/check-superuser)
|
||||
(api/check-404 (t2/select-one :model/Metabot :id id)))
|
||||
|
||||
(def ^:private default-entities-page-size 200)
|
||||
|
||||
(api.macros/defendpoint :get "/:id/entities"
|
||||
"List the entities this metabot has access to"
|
||||
[{:keys [id]} :- [:map [:id pos-int?]]]
|
||||
(api/check-superuser)
|
||||
(api/check-404 (t2/exists? :model/Metabot :id id))
|
||||
(let [limit (or (request/limit) default-entities-page-size)
|
||||
offset (or (request/offset) 0)
|
||||
root-collection (collection/root-collection-with-ui-details nil)
|
||||
entities (t2/select [:model/MetabotEntity
|
||||
:model_id
|
||||
:model
|
||||
[[:coalesce :card.name :collection.name] :name]
|
||||
:created_at
|
||||
[:parent_collection.id :collection_id]
|
||||
[:parent_collection.name :collection_name]]
|
||||
{:left-join [[:report_card :card] [:and [:in :model [[:inline "dataset"] [:inline "metric"]]] [:= :model_id :card.id]]
|
||||
[:collection :collection] [:and [:= :model [:inline "collection"]] [:= :model_id :collection.id]]
|
||||
;; TODO: How to join parent collections from the location column?
|
||||
[:collection :parent_collection] [:= :card.collection_id :parent_collection.id]]
|
||||
:where [:= :metabot_id id]
|
||||
:limit limit
|
||||
:offset offset
|
||||
:order-by [[:name :asc]]})
|
||||
total (t2/count :model/MetabotEntity :metabot_id id)]
|
||||
{:items (for [{:keys [collection_id model] :as entity} entities]
|
||||
(cond-> entity
|
||||
(and (nil? collection_id)
|
||||
(not= :collection model)) (assoc :collection_id (:id root-collection)
|
||||
:collection_name (:name root-collection))))
|
||||
:total total
|
||||
:limit limit
|
||||
:offset offset}))
|
||||
|
||||
(defn- column-input
|
||||
[answer-source-column]
|
||||
(some-> answer-source-column (select-keys [:name :type :description :table-reference])))
|
||||
|
||||
(defn- metric-input
|
||||
[{:keys [queryable-dimensions default-time-dimension-field-id] :as answer-source-metric}]
|
||||
(let [default-time-dimension (when default-time-dimension-field-id
|
||||
(-> (m/find-first (comp #{default-time-dimension-field-id} :field-id)
|
||||
queryable-dimensions)
|
||||
column-input))]
|
||||
(-> answer-source-metric
|
||||
(select-keys [:name :description])
|
||||
(assoc :queryable-dimensions (map column-input queryable-dimensions))
|
||||
(m/assoc-some :default-time-dimension default-time-dimension))))
|
||||
|
||||
(defn- model-input
|
||||
[answer-source-model]
|
||||
(-> answer-source-model
|
||||
(select-keys [:name :description :fields])
|
||||
(update :fields #(map column-input %))))
|
||||
|
||||
(defn- generate-sample-prompts
|
||||
[metabot-entity-ids]
|
||||
(lib.metadata.jvm/with-metadata-provider-cache
|
||||
(let [scope (metabot-v3.tools.u/metabot-scope metabot-entity-ids)
|
||||
{metrics :metric, models :model}
|
||||
(->> (for [[[card-type database-id] cards] (group-by (juxt :type :database_id) (keys scope))
|
||||
detail (map (fn [detail card] (assoc detail ::origin card))
|
||||
(metabot-v3.dummy-tools/cards-details card-type database-id cards nil)
|
||||
cards)]
|
||||
detail)
|
||||
(group-by :type))
|
||||
metric-inputs (map metric-input metrics)
|
||||
model-inputs (map model-input models)
|
||||
{:keys [table_questions metric_questions]}
|
||||
(metabot-v3.client/generate-example-questions {:metrics metric-inputs, :tables model-inputs})
|
||||
->prompt (fn [{:keys [questions]} {::keys [origin]}]
|
||||
(let [base {:metabot_entity_id (scope origin)
|
||||
:model (:type origin)
|
||||
:card_id (:id origin)}]
|
||||
(map #(assoc base :prompt %) questions)))
|
||||
metric-prompts (mapcat ->prompt metric_questions metrics)
|
||||
model-prompts (mapcat ->prompt table_questions models)]
|
||||
(when (seq metric-prompts)
|
||||
(t2/insert! :model/MetabotPrompt metric-prompts))
|
||||
(when (seq model-prompts)
|
||||
(t2/insert! :model/MetabotPrompt model-prompts)))))
|
||||
|
||||
(api.macros/defendpoint :put "/:id/entities"
|
||||
"Update the entities this metabot has access to"
|
||||
(api.macros/defendpoint :put "/:id"
|
||||
"Update a metabot instance"
|
||||
[{:keys [id]} :- [:map [:id pos-int?]]
|
||||
_query-params
|
||||
{:keys [items]} :- [:map
|
||||
[:items [:sequential [:map
|
||||
[:id pos-int?]
|
||||
[:model [:enum "dataset" "metric" "collection"]]]]]]]
|
||||
(api/check-superuser)
|
||||
(t2/with-transaction [_conn]
|
||||
(api/check-404 (t2/exists? :model/Metabot :id id))
|
||||
(let [new-entity-ids (into []
|
||||
(keep (fn [{model-id :id model :model}]
|
||||
(when-not (t2/exists? :model/MetabotEntity
|
||||
:metabot_id id
|
||||
:model model
|
||||
:model_id model-id)
|
||||
(t2/insert-returning-pk! :model/MetabotEntity
|
||||
{:metabot_id id
|
||||
:model model
|
||||
:model_id model-id}))))
|
||||
items)]
|
||||
(generate-sample-prompts new-entity-ids)))
|
||||
api/generic-204-no-content)
|
||||
|
||||
(api.macros/defendpoint :delete ["/:id/entities/:model/:model-id" :model #"dataset|metric|collection"]
|
||||
"Remove an entity from this metabot's access list"
|
||||
[{:keys [id model model-id]} :- [:map
|
||||
[:id pos-int?]
|
||||
[:model [:enum "dataset" "metric" "collection"]]
|
||||
[:model-id pos-int?]]]
|
||||
metabot-updates :- [:map {:closed true}
|
||||
[:use_verified_content {:optional true} :boolean]
|
||||
[:collection_id {:optional true} [:maybe pos-int?]]]]
|
||||
(api/check-superuser)
|
||||
(api/check-404 (t2/exists? :model/Metabot :id id))
|
||||
(t2/delete! :model/MetabotEntity
|
||||
:metabot_id id
|
||||
:model model
|
||||
:model_id model-id)
|
||||
api/generic-204-no-content)
|
||||
|
||||
(defn- delete-all-metabot-prompts
|
||||
[metabot-id]
|
||||
(t2/delete! :model/MetabotPrompt {:where [:exists {:select [:*]
|
||||
:from [[:metabot_entity :mbe]]
|
||||
:where [:and
|
||||
[:= :mbe.id :metabot_prompt.metabot_entity_id]
|
||||
[:= :mbe.metabot_id metabot-id]]}]}))
|
||||
(let [old-metabot (t2/select-one :model/Metabot :id id)]
|
||||
;; Prevent updating collection_id on the primary metabot instance
|
||||
(when (and (contains? metabot-updates :collection_id)
|
||||
(= (:entity_id old-metabot)
|
||||
(get-in metabot-v3.config/metabot-config [metabot-v3.config/internal-metabot-id :entity-id])))
|
||||
(api/check-400 false "Cannot update collection_id for the primary metabot instance."))
|
||||
;; Prevent enabling verified content without the premium feature
|
||||
(when (:use_verified_content metabot-updates)
|
||||
(premium-features/assert-has-feature :content-verification (tru "Content verification")))
|
||||
(let [old-vals (select-keys old-metabot (keys metabot-updates))]
|
||||
(when (not= old-vals metabot-updates)
|
||||
(t2/update! :model/Metabot id metabot-updates)
|
||||
(metabot-v3.suggested-prompts/delete-all-metabot-prompts id)
|
||||
(metabot-v3.suggested-prompts/generate-sample-prompts id))
|
||||
(t2/select-one :model/Metabot :id id))))
|
||||
|
||||
(api.macros/defendpoint :post "/:id/prompt-suggestions/regenerate"
|
||||
"Remove any existing prompt suggestions for the Metabot instance with `id` and generate new ones."
|
||||
@@ -164,9 +58,8 @@
|
||||
(api/check-superuser)
|
||||
(t2/with-transaction [_conn]
|
||||
(api/check-404 (t2/exists? :model/Metabot :id id))
|
||||
(when-let [entity-ids (not-empty (t2/select-pks-vec :model/MetabotEntity :metabot_id id))]
|
||||
(delete-all-metabot-prompts id)
|
||||
(generate-sample-prompts entity-ids)))
|
||||
(metabot-v3.suggested-prompts/delete-all-metabot-prompts id)
|
||||
(metabot-v3.suggested-prompts/generate-sample-prompts id))
|
||||
api/generic-204-no-content)
|
||||
|
||||
(api.macros/defendpoint :get "/:id/prompt-suggestions"
|
||||
@@ -180,15 +73,14 @@
|
||||
rand-fn (case (mdb/db-type)
|
||||
:postgres :random
|
||||
:rand)
|
||||
base-query (cond-> {:join [[{:select [:id :name :type :metabot_entity_id]
|
||||
:from [[(metabot-v3.tools.u/metabot-scope-query
|
||||
[:= :mbe.metabot_id id])
|
||||
base-query (cond-> {:join [[{:select [:id :name :type]
|
||||
:from [[(metabot-v3.tools.u/metabot-metrics-and-models-query id)
|
||||
:scope]]}
|
||||
:card]
|
||||
[:and
|
||||
[:= :card.id :metabot_prompt.card_id]
|
||||
[:= :card.metabot_entity_id :metabot_prompt.metabot_entity_id]]]
|
||||
:where [:and]}
|
||||
[:= :card.id :metabot_prompt.card_id]]]
|
||||
:where [:and
|
||||
[:= :metabot_prompt.metabot_id id]]}
|
||||
model (update :where conj [:= :card.type model])
|
||||
model_id (update :where conj [:= :card.id model_id]))
|
||||
total (t2/count :model/MetabotPrompt base-query)
|
||||
@@ -217,7 +109,7 @@
|
||||
"Delete all prompt suggestions for the metabot instance with `id`."
|
||||
[{:keys [id]} :- [:map [:id pos-int?]]]
|
||||
(api/check-superuser)
|
||||
(delete-all-metabot-prompts id)
|
||||
(metabot-v3.suggested-prompts/delete-all-metabot-prompts id)
|
||||
api/generic-204-no-content)
|
||||
|
||||
(api.macros/defendpoint :delete "/:id/prompt-suggestions/:prompt-id"
|
||||
@@ -228,11 +120,7 @@
|
||||
(api/check-superuser)
|
||||
(t2/delete! :model/MetabotPrompt {:where [:and
|
||||
[:= :id prompt-id]
|
||||
[:exists {:select [:*]
|
||||
:from [[:metabot_entity :mbe]]
|
||||
:where [:and
|
||||
[:= :mbe.id :metabot_prompt.metabot_entity_id]
|
||||
[:= :mbe.metabot_id id]]}]]})
|
||||
[:= :metabot_id id]]})
|
||||
api/generic-204-no-content)
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
(def metabot-config
|
||||
"The name of the collection exposed by the answer-sources tool."
|
||||
{internal-metabot-id {:profile-id "experimental"
|
||||
{internal-metabot-id {:profile-id "internal"
|
||||
:entity-id "metabotmetabotmetabot"}
|
||||
embedded-metabot-id {:profile-id "default"
|
||||
embedded-metabot-id {:profile-id "embedding"
|
||||
:entity-id "embeddedmetabotmetabo"}})
|
||||
|
||||
(defn metabot-id->profile-id
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns metabase-enterprise.metabot-v3.context
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.util.json :as json]
|
||||
@@ -53,28 +54,21 @@
|
||||
:api/endpoints)]
|
||||
(str "backend:/api/ee/metabot-tools" url)))
|
||||
|
||||
(def ^:private max-database-tables
|
||||
"If the number of tables in the database doesn't exceed this number, we send them all to the agent."
|
||||
100)
|
||||
|
||||
(defn- database-tables-for-context
|
||||
"Get database tables formatted for metabot context, prioritizing tables used in the query (if provided), then filling up to the limit with most viewed tables."
|
||||
([database-id] (database-tables-for-context database-id nil))
|
||||
([database-id {:keys [all-tables-limit query] :or {all-tables-limit max-database-tables}}]
|
||||
(when database-id
|
||||
(try
|
||||
(let [used-tables (if query
|
||||
(table-utils/used-tables query)
|
||||
[])
|
||||
used-table-ids (set (map :id used-tables))]
|
||||
(table-utils/database-tables database-id
|
||||
{:all-tables-limit all-tables-limit
|
||||
:priority-tables used-tables
|
||||
:exclude-table-ids used-table-ids}))
|
||||
(catch Exception e
|
||||
(log/error e "Error getting database tables for context")
|
||||
;; If we can't get table info, just return empty - don't break the context
|
||||
[])))))
|
||||
"Get database tables formatted for metabot context. Only includes tables used in the query, formatted for API output.
|
||||
Removes duplicate tables by id while preserving first occurrence order."
|
||||
[{:keys [query]}]
|
||||
(try
|
||||
(if query
|
||||
(let [used-tables (table-utils/used-tables query)
|
||||
tables (table-utils/enhanced-database-tables (:database query)
|
||||
{:priority-tables used-tables
|
||||
:all-tables-limit (count used-tables)})]
|
||||
(m/distinct-by :id tables))
|
||||
[])
|
||||
(catch Exception e
|
||||
(log/error e "Error getting database tables for context")
|
||||
[])))
|
||||
|
||||
(defn- enhance-context-with-schema
|
||||
"Enhance context by adding table schema information for native queries"
|
||||
@@ -84,10 +78,9 @@
|
||||
(mapv (fn [item]
|
||||
(if (and (#{:native "native"} (get-in item [:query :type]))
|
||||
(get-in item [:query :database]))
|
||||
(let [database-id (get-in item [:query :database])
|
||||
tables (database-tables-for-context database-id {:query (:query item)})]
|
||||
(let [tables (database-tables-for-context {:query (:query item)})]
|
||||
(if (seq tables)
|
||||
(assoc item :database_schema tables)
|
||||
(assoc item :used_tables tables)
|
||||
item))
|
||||
item))
|
||||
user-viewing)]
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.types.isa :as lib.types.isa]
|
||||
[metabase.lib.util :as lib.util]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.humanization :as u.humanization]
|
||||
[metabase.warehouse-schema.models.field-values :as field-values]
|
||||
@@ -17,6 +16,16 @@
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- verified-review?
|
||||
"Return true if the most recent ModerationReview for the given item id/type is verified."
|
||||
[id item-type]
|
||||
(let [review (t2/select-one [:model/ModerationReview :status]
|
||||
:moderated_item_id id
|
||||
:moderated_item_type item-type
|
||||
:most_recent true
|
||||
{:order-by [[:id :desc]]})]
|
||||
(= (:status review) "verified")))
|
||||
|
||||
(defn get-current-user
|
||||
"Get information about the current user."
|
||||
[]
|
||||
@@ -34,7 +43,11 @@
|
||||
[{:keys [dashboard-id]}]
|
||||
(if-let [dashboard (t2/select-one [:model/Dashboard :id :description :name :collection_id] dashboard-id)]
|
||||
(do (api/read-check dashboard)
|
||||
{:structured-output (-> dashboard (dissoc :collection_id) (assoc :type :dashboard))})
|
||||
{:structured-output
|
||||
(-> dashboard
|
||||
(dissoc :collection_id)
|
||||
(assoc :type :dashboard
|
||||
:verified (verified-review? dashboard-id "dashboard")))})
|
||||
{:output "dashboard not found"}))
|
||||
|
||||
(defn- get-field-values [id->values id]
|
||||
@@ -50,14 +63,6 @@
|
||||
(map #(m/assoc-some % :field-values (some->> % :id (get-field-values id->values))) cols))
|
||||
cols))
|
||||
|
||||
(defn- add-table-reference
|
||||
[query col]
|
||||
(cond-> col
|
||||
(and (:fk-field-id col)
|
||||
(:table-id col))
|
||||
(assoc :table-reference (-> (lib/display-name query (lib.metadata/field query (:fk-field-id col)))
|
||||
lib.util/strip-id))))
|
||||
|
||||
(defn metric-details
|
||||
"Get metric details as returned by tools."
|
||||
([id] (metric-details id nil))
|
||||
@@ -78,7 +83,7 @@
|
||||
(lib/remove-all-breakouts metric-query))
|
||||
visible-cols (when query-needed?
|
||||
(->> (lib/visible-columns base-query)
|
||||
(map #(add-table-reference base-query %))))
|
||||
(map #(metabot-v3.tools.u/add-table-reference base-query %))))
|
||||
col->index (when query-needed?
|
||||
(into {} (map-indexed (fn [i col] [col i])) visible-cols))
|
||||
col-index (when query-needed?
|
||||
@@ -92,16 +97,17 @@
|
||||
:type :metric
|
||||
:name (:name card)
|
||||
:description (:description card)
|
||||
:default-time-dimension-field-id (when default-temporal-breakout
|
||||
:default_time_dimension_field_id (when default-temporal-breakout
|
||||
(-> (metabot-v3.tools.u/->result-column
|
||||
metric-query
|
||||
default-temporal-breakout
|
||||
(col-index default-temporal-breakout)
|
||||
field-id-prefix)
|
||||
:field-id))}
|
||||
:field_id))
|
||||
:verified (verified-review? id "card")}
|
||||
with-queryable-dimensions?
|
||||
(assoc :queryable-dimensions (into []
|
||||
(comp (map #(add-table-reference base-query %))
|
||||
(comp (map #(metabot-v3.tools.u/add-table-reference base-query %))
|
||||
(map #(metabot-v3.tools.u/->result-column
|
||||
metric-query % (col-index %) field-id-prefix)))
|
||||
(->> (lib/filterable-columns base-query)
|
||||
@@ -118,7 +124,7 @@
|
||||
([db-metric metadata-provider options]
|
||||
(-> db-metric
|
||||
(metric-details metadata-provider (assoc options :with-queryable-dimensions? false))
|
||||
(select-keys [:id :type :name :description :default-time-dimension-field-id]))))
|
||||
(select-keys [:id :type :name :description :default_time_dimension_field_id]))))
|
||||
|
||||
(defn- table-details
|
||||
([id] (table-details id nil))
|
||||
@@ -129,25 +135,29 @@
|
||||
:as options}]
|
||||
(when-let [base (if metadata-provider
|
||||
(lib.metadata/table metadata-provider id)
|
||||
(metabot-v3.tools.u/get-table id :db_id :description :name))]
|
||||
(metabot-v3.tools.u/get-table id :db_id :description :name :schema))]
|
||||
(let [query-needed? (or with-fields? with-metrics?)
|
||||
db-id (if metadata-provider (:db-id base) (:db_id base))
|
||||
mp (when query-needed?
|
||||
(or metadata-provider
|
||||
(lib.metadata.jvm/application-database-metadata-provider (:db_id base))))
|
||||
(lib.metadata.jvm/application-database-metadata-provider db-id)))
|
||||
table-query (when query-needed?
|
||||
(lib/query mp (lib.metadata/table mp id)))
|
||||
cols (when with-fields?
|
||||
(->> (lib/visible-columns table-query)
|
||||
field-values-fn
|
||||
(map #(add-table-reference table-query %))))
|
||||
(map #(metabot-v3.tools.u/add-table-reference table-query %))))
|
||||
field-id-prefix (when with-fields?
|
||||
(metabot-v3.tools.u/table-field-id-prefix id))]
|
||||
(-> {:id id
|
||||
:type :table
|
||||
:fields (into [] (map-indexed #(metabot-v3.tools.u/->result-column table-query %2 %1 field-id-prefix)) cols)
|
||||
;; :name should be (lib/display-name table-query), but we want to avoid creating the query if possible
|
||||
:name (some->> (:name base)
|
||||
(u.humanization/name->human-readable-name :simple))}
|
||||
:name (:name base)
|
||||
;; :display_name should be (lib/display-name table-query), but we want to avoid creating the query if possible
|
||||
:display_name (some->> (:name base)
|
||||
(u.humanization/name->human-readable-name :simple))
|
||||
:database_id db-id
|
||||
:database_schema (:schema base)}
|
||||
(m/assoc-some :description (:description base)
|
||||
:metrics (when with-metrics?
|
||||
(not-empty (mapv #(convert-metric % mp options)
|
||||
@@ -181,13 +191,18 @@
|
||||
cols (when with-fields?
|
||||
(->> (lib/visible-columns card-query)
|
||||
field-values-fn
|
||||
(map #(add-table-reference card-query %))))
|
||||
(map #(metabot-v3.tools.u/add-table-reference card-query %))))
|
||||
field-id-prefix (metabot-v3.tools.u/card-field-id-prefix id)]
|
||||
(-> {:id id
|
||||
:type card-type
|
||||
:fields (into [] (map-indexed #(metabot-v3.tools.u/->result-column card-query %2 %1 field-id-prefix)) cols)
|
||||
:name (:name base)
|
||||
:queryable-foreign-key-tables []}
|
||||
:display_name (some->> (:name base)
|
||||
(u.humanization/name->human-readable-name :simple))
|
||||
:database_id (:database_id base)
|
||||
:queryable-foreign-key-tables []
|
||||
:verified (verified-review? id "card")}
|
||||
|
||||
(m/assoc-some :description (:description base)
|
||||
:metrics (when with-metrics?
|
||||
(not-empty (mapv #(convert-metric % metadata-provider options)
|
||||
@@ -293,7 +308,7 @@
|
||||
details (if (int? report-id)
|
||||
(let [details (card-details report-id options)]
|
||||
(some-> details
|
||||
(select-keys [:id :type :description :name])
|
||||
(select-keys [:id :type :description :name :verified])
|
||||
(assoc :result-columns (:fields details))))
|
||||
"invalid report_id")]
|
||||
(if (map? details)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
(ns metabase-enterprise.metabot-v3.init
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.settings]))
|
||||
[metabase-enterprise.metabot-v3.settings]
|
||||
[metabase-enterprise.metabot-v3.task.suggested-prompts-generator]))
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
|
||||
;;; --------------------------------------------------- Hydration ----------------------------------------------------
|
||||
|
||||
(methodical/defmethod t2/batched-hydrate [:model/Metabot :entities]
|
||||
"Hydrate the list of entities for a collection of metabots."
|
||||
(methodical/defmethod t2/batched-hydrate [:model/Metabot :prompts]
|
||||
"Hydrate the list of prompts for a collection of metabots."
|
||||
[_model k metabots]
|
||||
(mi/instances-with-hydrated-data
|
||||
metabots k
|
||||
#(group-by :metabot_id
|
||||
(t2/select :model/MetabotEntity {:where [:in :metabot_id (map :id metabots)]}))
|
||||
(t2/select :model/MetabotPrompt {:where [:in :metabot_id (map :id metabots)]}))
|
||||
:id
|
||||
{:default []}))
|
||||
|
||||
@@ -35,14 +35,15 @@
|
||||
[:name])
|
||||
|
||||
(defmethod serdes/dependencies "Metabot"
|
||||
[{:keys [entities]}]
|
||||
(into #{} (mapcat serdes/dependencies) entities))
|
||||
[{:keys [prompts]}]
|
||||
(set (mapcat serdes/dependencies prompts)))
|
||||
|
||||
(defmethod serdes/generate-path "Metabot" [_ metabot]
|
||||
[(serdes/infer-self-path "Metabot" metabot)])
|
||||
|
||||
(defmethod serdes/make-spec "Metabot" [_model-name opts]
|
||||
{:copy [:name :description :entity_id]
|
||||
:transform {:created_at (serdes/date)
|
||||
:updated_at (serdes/date)
|
||||
:entities (serdes/nested :model/MetabotEntity :metabot_id opts)}})
|
||||
{:copy [:name :description :entity_id :use_verified_content]
|
||||
:transform {:created_at (serdes/date)
|
||||
:updated_at (serdes/date)
|
||||
:collection_id (serdes/fk :model/Collection)
|
||||
:prompts (serdes/nested :model/MetabotPrompt :metabot_id opts)}})
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
(ns metabase-enterprise.metabot-v3.models.metabot-entity
|
||||
(:require
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
;;; --------------------------------------------------- Lifecycle ----------------------------------------------------
|
||||
|
||||
(methodical/defmethod t2/table-name :model/MetabotEntity [_model] :metabot_entity)
|
||||
|
||||
(doto :model/MetabotEntity
|
||||
(derive :metabase/model)
|
||||
(derive ::mi/read-policy.superuser)
|
||||
(derive ::mi/write-policy.superuser)
|
||||
(derive :hook/entity-id)
|
||||
(derive :hook/created-at-timestamped?))
|
||||
|
||||
(t2/deftransforms :model/MetabotEntity
|
||||
{:model mi/transform-keyword})
|
||||
|
||||
;;; ------------------------------------------------- Serialization -------------------------------------------------
|
||||
|
||||
(defmethod serdes/hash-fields :model/MetabotEntity
|
||||
[_table]
|
||||
[:metabot_id :model :model_id])
|
||||
|
||||
(defmethod serdes/dependencies "MetabotEntity"
|
||||
[{:keys [model model_id prompts]}]
|
||||
(into #{[{:model (case model
|
||||
"collection" "Collection"
|
||||
("dataset" "metric") "Card")
|
||||
:id model_id}]}
|
||||
(mapcat serdes/dependencies prompts)))
|
||||
|
||||
(defmethod serdes/generate-path "MetabotEntity" [_ entity]
|
||||
[(serdes/infer-self-path "Metabot" (t2/select-one :model/Metabot :id (:metabot_id entity)))
|
||||
(serdes/infer-self-path "MetabotEntity" entity)])
|
||||
|
||||
(defmethod serdes/make-spec "MetabotEntity" [_model-name opts]
|
||||
{:copy [:entity_id]
|
||||
:transform {:created_at (serdes/date)
|
||||
:model (serdes/kw)
|
||||
:model_id {::fk true
|
||||
:export-with-context (fn [{:keys [model model_id]} _ _]
|
||||
(serdes/*export-fk* model_id (case model
|
||||
:collection :model/Collection
|
||||
(:dataset :metric) :model/Card)))
|
||||
:import-with-context (fn [{:keys [model model_id]} _ _]
|
||||
(serdes/*import-fk* model_id (case model
|
||||
"collection" :model/Collection
|
||||
("dataset" "metric") :model/Card)))}
|
||||
:metabot_id (serdes/parent-ref)
|
||||
:prompts (serdes/nested :model/MetabotPrompt :metabot_entity_id opts)}})
|
||||
@@ -22,10 +22,10 @@
|
||||
|
||||
(defmethod serdes/hash-fields :model/MetabotPrompt
|
||||
[_table]
|
||||
[:metabot_entity_id :model :card_id :prompt])
|
||||
[:metabot_id :model :card_id :prompt])
|
||||
|
||||
(defmethod serdes/generate-path "MetabotPrompt" [_ entity]
|
||||
(conj (serdes/generate-path "MetabotEntity" (t2/select-one :model/MetabotEntity (:metabot_entity_id entity)))
|
||||
(conj (serdes/generate-path "Metabot" (t2/select-one :model/Metabot (:metabot_id entity)))
|
||||
(serdes/infer-self-path "MetabotPrompt" entity)))
|
||||
|
||||
(defmethod serdes/dependencies "MetabotPrompt" [prompt]
|
||||
@@ -33,8 +33,8 @@
|
||||
|
||||
(defmethod serdes/make-spec "MetabotPrompt" [_model-name _opts]
|
||||
{:copy [:entity_id :prompt]
|
||||
:transform {:created_at (serdes/date)
|
||||
:updated_at (serdes/date)
|
||||
:model (serdes/kw)
|
||||
:card_id (serdes/fk :model/Card)
|
||||
:metabot_entity_id (serdes/parent-ref)}})
|
||||
:transform {:created_at (serdes/date)
|
||||
:updated_at (serdes/date)
|
||||
:model (serdes/kw)
|
||||
:card_id (serdes/fk :model/Card)
|
||||
:metabot_id (serdes/parent-ref)}})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
(ns metabase-enterprise.metabot-v3.suggested-prompts
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.dummy-tools :as metabot-v3.dummy-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.lib-be.metadata.jvm :as lib.metadata.jvm]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- column-input
|
||||
[answer-source-column]
|
||||
(some-> answer-source-column (select-keys [:name :type :description :table-reference])))
|
||||
|
||||
(defn- metric-input
|
||||
[{:keys [queryable-dimensions default-time-dimension-field-id] :as answer-source-metric}]
|
||||
(let [default-time-dimension (when default-time-dimension-field-id
|
||||
(-> (m/find-first (comp #{default-time-dimension-field-id} :field-id)
|
||||
queryable-dimensions)
|
||||
column-input))]
|
||||
(-> answer-source-metric
|
||||
(select-keys [:name :description])
|
||||
(assoc :queryable-dimensions (map column-input queryable-dimensions))
|
||||
(m/assoc-some :default-time-dimension default-time-dimension))))
|
||||
|
||||
(defn- model-input
|
||||
[answer-source-model]
|
||||
(-> answer-source-model
|
||||
(select-keys [:name :description :fields])
|
||||
(update :fields #(map column-input %))))
|
||||
|
||||
(def ^:private default-opts
|
||||
{:limit 20})
|
||||
|
||||
(defn generate-sample-prompts
|
||||
"Generate suggested propmpts for instance of Metabot."
|
||||
[metabot-id & {:as opts}]
|
||||
(let [opts (merge default-opts opts)]
|
||||
(lib.metadata.jvm/with-metadata-provider-cache
|
||||
(let [{metrics :metric models :model} (->> (metabot-v3.tools.u/get-metrics-and-models metabot-id opts)
|
||||
(sort-by :view_count >)
|
||||
(group-by :type))
|
||||
;; Limit to 5 metrics and 5 models
|
||||
limited-cards (concat (take 5 metrics) (take 5 models))
|
||||
{metrics :metric, models :model}
|
||||
(->> (for [[[card-type database-id] group-cards] (group-by (juxt :type :database_id) limited-cards)
|
||||
detail (map (fn [detail card] (assoc detail ::origin card))
|
||||
(metabot-v3.dummy-tools/cards-details card-type database-id group-cards nil)
|
||||
group-cards)]
|
||||
detail)
|
||||
(group-by :type))
|
||||
metric-inputs (map metric-input metrics)
|
||||
model-inputs (map model-input models)
|
||||
{:keys [table_questions metric_questions]}
|
||||
(metabot-v3.client/generate-example-questions {:metrics metric-inputs, :tables model-inputs})
|
||||
->prompt (fn [{:keys [questions]} {::keys [origin]}]
|
||||
(let [base {:metabot_id metabot-id
|
||||
:model (:type origin)
|
||||
:card_id (:id origin)}]
|
||||
(map #(assoc base :prompt %) questions)))
|
||||
metric-prompts (mapcat ->prompt metric_questions metrics)
|
||||
model-prompts (mapcat ->prompt table_questions models)]
|
||||
(when (seq metric-prompts)
|
||||
(t2/insert! :model/MetabotPrompt metric-prompts))
|
||||
(when (seq model-prompts)
|
||||
(t2/insert! :model/MetabotPrompt model-prompts))))))
|
||||
|
||||
(defn delete-all-metabot-prompts
|
||||
"Drop suggested propmpts for instance of Metabot."
|
||||
[metabot-id]
|
||||
(t2/delete! :model/MetabotPrompt {:where [:= :metabot_id metabot-id]}))
|
||||
@@ -3,9 +3,14 @@
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase-enterprise.metabot-v3.query-analyzer :as query-analyzer]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.lib-be.metadata.jvm :as lib.metadata.jvm]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.humanization :as u.humanization]
|
||||
[toucan2.core :as t2]
|
||||
[toucan2.realize :as t2.realize])
|
||||
(:import
|
||||
@@ -59,13 +64,62 @@
|
||||
all-tables (concat priority-tables fill-tables)
|
||||
all-tables (take all-tables-limit all-tables)]
|
||||
(mapv (fn [{:keys [fields] :as table}]
|
||||
(merge (select-keys table [:name :schema :description])
|
||||
(merge (select-keys table [:id :name :schema :description])
|
||||
{:columns (mapv (fn [{:keys [database_type] :as field}]
|
||||
(merge (select-keys field [:name :description])
|
||||
(merge (select-keys field [:id :name :description])
|
||||
{:data_type database_type}))
|
||||
fields)}))
|
||||
all-tables))))
|
||||
|
||||
(defn enhanced-database-tables
|
||||
"Get database tables formatted with the new metabot tools schema format.
|
||||
|
||||
Returns tables with :type, :display_name, :database_id, :database_schema, :fields (with field-id), :metrics.
|
||||
This format is used by metabot context and other modern tools."
|
||||
([database-id]
|
||||
(enhanced-database-tables database-id nil))
|
||||
([database-id {:keys [all-tables-limit priority-tables exclude-table-ids]
|
||||
:or {all-tables-limit max-database-tables
|
||||
priority-tables []
|
||||
exclude-table-ids #{}}}]
|
||||
(let [priority-table-ids (set (map :id priority-tables))
|
||||
;; Fetch most viewed tables, excluding priority tables and excluded tables
|
||||
fill-tables (t2/select [:model/Table :id :db_id :name :schema :description]
|
||||
:db_id database-id
|
||||
:active true
|
||||
:visibility_type nil
|
||||
{:where (mi/visible-filter-clause :model/Table
|
||||
:id
|
||||
{:user-id api/*current-user-id*
|
||||
:is-superuser? api/*is-superuser?*}
|
||||
{:perms/view-data :unrestricted
|
||||
:perms/create-queries :query-builder-and-native})
|
||||
:order-by [[:view_count :desc]]
|
||||
:limit all-tables-limit})
|
||||
fill-tables (remove #(or (priority-table-ids (:id %))
|
||||
(exclude-table-ids (:id %))) fill-tables)
|
||||
all-tables (concat priority-tables fill-tables)
|
||||
all-tables (take all-tables-limit all-tables)]
|
||||
(lib.metadata.jvm/with-metadata-provider-cache
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider database-id)
|
||||
table-ids (map :id all-tables)
|
||||
_ (lib.metadata/bulk-metadata mp :metadata/table table-ids)]
|
||||
(mapv (fn [{:keys [id name schema description]}]
|
||||
(let [table-query (lib/query mp (lib.metadata/table mp id))
|
||||
cols (->> (lib/visible-columns table-query)
|
||||
(map #(metabot-v3.tools.u/add-table-reference table-query %)))
|
||||
field-id-prefix (metabot-v3.tools.u/table-field-id-prefix id)]
|
||||
{:id id
|
||||
:type :table
|
||||
:name name
|
||||
:display_name (u.humanization/name->human-readable-name :simple name)
|
||||
:database_id database-id
|
||||
:database_schema schema
|
||||
:description description
|
||||
:fields (into [] (map-indexed #(metabot-v3.tools.u/->result-column table-query %2 %1 field-id-prefix) cols))
|
||||
:metrics []}))
|
||||
all-tables))))))
|
||||
|
||||
(defn similar?
|
||||
"Check if two strings are similar using Levenshtein distance with a max distance of 4."
|
||||
[left right]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
(ns metabase-enterprise.metabot-v3.task.suggested-prompts-generator
|
||||
"Job to execute on start of an instance"
|
||||
(:require
|
||||
[clojurewerkz.quartzite.jobs :as jobs]
|
||||
[clojurewerkz.quartzite.triggers :as triggers]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.suggested-prompts :as metabot-v3.suggested-prompts]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.task.core :as task]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.time Instant)
|
||||
(java.util Date)
|
||||
(org.quartz DisallowConcurrentExecution)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private generator-job-key (jobs/key "metabase.task.metabot-v3.suggested-prompts-generator.job"))
|
||||
(def ^:private generator-trigger-key (triggers/key "metabase.task.metabot-v3.suggested-prompts-generator.trigger"))
|
||||
|
||||
(defn- maybe-generate-suggested-prompts! []
|
||||
(when (premium-features/has-feature? :metabot-v3)
|
||||
(let [metabot-eid (get-in metabot-v3.config/metabot-config
|
||||
[metabot-v3.config/internal-metabot-id :entity-id])
|
||||
metabot-id (t2/select-one-pk :model/Metabot :entity_id metabot-eid)
|
||||
suggested-prompts-cnt (t2/count :model/MetabotPrompt :metabot_id metabot-id)]
|
||||
(if (zero? suggested-prompts-cnt)
|
||||
(do
|
||||
(log/info "No suggested prompts found. Generating suggested prompts.")
|
||||
(metabot-v3.suggested-prompts/generate-sample-prompts metabot-id)
|
||||
(log/info "Suggested prompts generated successfully."))
|
||||
(log/info "Suggested prompts are present. Not generating.")))))
|
||||
|
||||
(task/defjob ^{DisallowConcurrentExecution true
|
||||
:doc "Initial _suggested prompts_ generation for internal Metabot."}
|
||||
SuggestedPromptsGenerator [_ctx]
|
||||
(maybe-generate-suggested-prompts!))
|
||||
|
||||
(defmethod task/init! ::SuggestedPromptsGenerator
|
||||
[_]
|
||||
(when (premium-features/has-feature? :metabot-v3)
|
||||
(let [job
|
||||
(jobs/build
|
||||
(jobs/of-type SuggestedPromptsGenerator)
|
||||
(jobs/with-identity generator-job-key))
|
||||
trigger (triggers/build
|
||||
(triggers/with-identity generator-trigger-key)
|
||||
;; Start the job a moment after startup.
|
||||
(triggers/start-at (Date/from (.plusSeconds (Instant/now) 10))))]
|
||||
(task/schedule-task! job trigger))))
|
||||
@@ -16,9 +16,9 @@
|
||||
:as metabot-v3.tools.create-dashboard-subscription]
|
||||
[metabase-enterprise.metabot-v3.tools.field-stats :as metabot-v3.tools.field-stats]
|
||||
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
|
||||
[metabase-enterprise.metabot-v3.tools.find-metric :as metabot-v3.tools.find-metric]
|
||||
[metabase-enterprise.metabot-v3.tools.find-outliers :as metabot-v3.tools.find-outliers]
|
||||
[metabase-enterprise.metabot-v3.tools.generate-insights :as metabot-v3.tools.generate-insights]
|
||||
[metabase-enterprise.metabot-v3.tools.search :as metabot-v3.tools.search]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.response :as api.response]
|
||||
@@ -218,6 +218,23 @@
|
||||
[:limit {:optional true} [:maybe :int]]]
|
||||
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
|
||||
|
||||
(mr/def ::query-datasource-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:table_id {:optional true} :int]
|
||||
[:model_id {:optional true} :int]
|
||||
[:fields {:optional true} [:maybe [:sequential ::field]]]
|
||||
[:filters {:optional true} [:maybe [:sequential ::filter]]]
|
||||
[:aggregations {:optional true} [:maybe [:sequential ::aggregation]]]
|
||||
[:group_by {:optional true} [:maybe [:sequential ::group-by]]]
|
||||
[:order_by {:optional true} [:maybe [:sequential [:map
|
||||
[:field ::field]
|
||||
[:direction [:enum {:encode/tool-api-request keyword} "asc" "desc"]]]]]]
|
||||
[:limit {:optional true} [:maybe :int]]]
|
||||
[:fn {:error/message "Exactly one of table_id and model_id required"}
|
||||
#(= (count (select-keys % [:table_id :model_id])) 1)]
|
||||
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
|
||||
|
||||
(mr/def ::count
|
||||
[:and
|
||||
:int
|
||||
@@ -379,7 +396,8 @@
|
||||
[:merge
|
||||
::basic-metric
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:queryable_dimensions {:optional true} ::columns]]])
|
||||
[:queryable_dimensions {:optional true} ::columns]
|
||||
[:verified {:optional true} :boolean]]])
|
||||
|
||||
(mr/def ::find-metric-result
|
||||
[:or
|
||||
@@ -443,7 +461,6 @@
|
||||
[:name :string]
|
||||
[:email_address :string]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-dashboard-details-result
|
||||
[:or
|
||||
[:map
|
||||
@@ -452,7 +469,8 @@
|
||||
[:id :int]
|
||||
[:type [:= :dashboard]]
|
||||
[:name :string]
|
||||
[:description {:optional true} :string]]]]
|
||||
[:description {:optional true} :string]
|
||||
[:verified {:optional true} :boolean]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-metric-details-arguments
|
||||
@@ -507,7 +525,8 @@
|
||||
[:type [:= :question]]
|
||||
[:name :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:result_columns ::columns]]]]
|
||||
[:result_columns ::columns]
|
||||
[:verified {:optional true} :boolean]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-document-details-arguments
|
||||
@@ -581,6 +600,9 @@
|
||||
[:id :int]
|
||||
[:type [:enum :model :table]]
|
||||
[:name :string]
|
||||
[:display_name :string]
|
||||
[:database_id :int]
|
||||
[:database_schema {:optional true} [:maybe :string]] ; Schema name, if applicable
|
||||
[:fields ::columns]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:metrics {:optional true} [:sequential ::basic-metric]]])
|
||||
@@ -674,20 +696,6 @@
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/find-metric" :- [:merge ::find-metric-result ::tool-request]
|
||||
"Find a metric matching a description."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments [:map [:message :string]]]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :find-metric) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::find-metric-result
|
||||
(metabot-v3.tools.find-metric/find-metric arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/find-outliers" :- [:merge ::find-outliers-result ::tool-request]
|
||||
"Find outliers in the values provided by a data source for a given column."
|
||||
[_route-params
|
||||
@@ -873,6 +881,90 @@
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
;; TODO tsplude - drop the `/query-model` endpoint and filter logic in favor of this
|
||||
(api.macros/defendpoint :post "/query-datasource" :- [:merge ::filtering-result ::tool-request]
|
||||
"Construct a query from a model or table data source."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::query-datasource-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :query-datasource) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::query-datasource-arguments
|
||||
arguments (mtx/transformer {:name :tool-api-request}))]
|
||||
(doto (-> (mc/decode ::filtering-result
|
||||
(metabot-v3.tools.filters/query-datasource arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(mr/def ::search-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:term_queries {:optional true} [:maybe [:sequential :string]]]
|
||||
[:semantic_queries {:optional true} [:maybe [:sequential :string]]]
|
||||
[:entity_types {:optional true} [:maybe [:sequential [:enum "table" "model" "question" "dashboard" "metric" "database"]]]]
|
||||
[:database_id {:optional true} [:maybe :int]]
|
||||
[:created_at {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:last_edited_at {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:limit {:optional true, :default 50} [:and :int [:fn #(<= 1 % 100)]]]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(update-keys % (comp keyword u/->kebab-case-en))}]])
|
||||
|
||||
(mr/def ::search-result-item
|
||||
"Unified schema for search result items."
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:type [:enum :table :model :dashboard :question :metric :database]]
|
||||
[:name :string]
|
||||
[:display_name {:optional true} [:maybe :string]]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:database_id {:optional true} [:maybe :int]]
|
||||
[:database_schema {:optional true} [:maybe :string]]
|
||||
[:verified {:optional true} [:maybe :boolean]]
|
||||
[:updated_at {:optional true} [:maybe :string]]
|
||||
[:created_at {:optional true} [:maybe :string]]
|
||||
[:collection {:optional true} [:maybe [:map
|
||||
[:name {:optional true} [:maybe :string]]
|
||||
[:authority_level {:optional true} [:maybe :string]]]]]])
|
||||
|
||||
(mr/def ::search-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:map
|
||||
[:data [:sequential ::search-result-item]]
|
||||
[:total_count :int]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(api.macros/defendpoint :post "/search" :- [:merge ::search-result ::tool-request]
|
||||
"Enhanced search with term and semantic queries using Reciprocal Rank Fusion."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments {:optional true} ::search-arguments]]
|
||||
::tool-request]
|
||||
request]
|
||||
(metabot-v3.context/log (assoc body :api :search) :llm.log/llm->be)
|
||||
(try
|
||||
(let [options (mc/encode ::search-arguments
|
||||
arguments (mtx/transformer {:name :tool-api-request}))
|
||||
metabot-id (:metabot-v3/metabot-id request)
|
||||
results (metabot-v3.tools.search/search
|
||||
(assoc options :metabot-id metabot-id))
|
||||
response-data {:data results
|
||||
:total_count (count results)}]
|
||||
(doto (-> (mc/decode ::search-result
|
||||
{:structured_output response-data}
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
(catch Exception e
|
||||
(log/error e "Error in search")
|
||||
(doto (-> {:output (str "Search failed: " (or (ex-message e) "Unknown error"))}
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))))
|
||||
|
||||
(defn- enforce-authentication
|
||||
"Middleware that returns a 401 response if no `ai-session` can be found for `request`."
|
||||
[handler]
|
||||
|
||||