Compare commits
79 Commits
dependency
...
python-run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64977bffa6 | ||
|
|
25dddd8f32 | ||
|
|
ee806be8b7 | ||
|
|
0ea6e387c0 | ||
|
|
f6f128b87d | ||
|
|
8cb9ca883d | ||
|
|
384d0b5d3b | ||
|
|
1561702bdb | ||
|
|
4d0eef7217 | ||
|
|
cc6827ae8c | ||
|
|
08dffba554 | ||
|
|
18b59c1b4d | ||
|
|
d56be957f0 | ||
|
|
6631ed5389 | ||
|
|
16c4a16025 | ||
|
|
2219786d93 | ||
|
|
93052b8a8e | ||
|
|
e01cbfc87e | ||
|
|
899d6edb3c | ||
|
|
5705ba7f81 | ||
|
|
0261a15e7b | ||
|
|
02cec9f38d | ||
|
|
8fa9204d57 | ||
|
|
88229c321e | ||
|
|
4d0d576c13 | ||
|
|
7e4dfde94f | ||
|
|
85fc7cb3fd | ||
|
|
16dc5bece0 | ||
|
|
3f15dcd0ad | ||
|
|
6e17e96bf0 | ||
|
|
59dc6a1376 | ||
|
|
4614a37972 | ||
|
|
648039ca1a | ||
|
|
2804f26f4b | ||
|
|
158b78fb25 | ||
|
|
9b82a4d92d | ||
|
|
5fb41fd3b1 | ||
|
|
db70faad90 | ||
|
|
dddf9d679c | ||
|
|
c6c0bf73dd | ||
|
|
c5da59566e | ||
|
|
f914bf8714 | ||
|
|
448e9c8278 | ||
|
|
0ac7bce92b | ||
|
|
ccd8a11ee7 | ||
|
|
5164a08b2a | ||
|
|
e50317d86d | ||
|
|
3800f7452a | ||
|
|
f89073a6bd | ||
|
|
d848a88e64 | ||
|
|
356025f41b | ||
|
|
a0c2ebd9e9 | ||
|
|
c9bc786c60 | ||
|
|
1f024e148d | ||
|
|
7c8a53c4d5 | ||
|
|
bc28d50013 | ||
|
|
d0af14f90d | ||
|
|
6afcf2aed5 | ||
|
|
99d7d54af3 | ||
|
|
1610538386 | ||
|
|
1d94009c3d | ||
|
|
b8360b6da3 | ||
|
|
cb13bdc086 | ||
|
|
1704f14bda | ||
|
|
593ecfb952 | ||
|
|
611220c5e0 | ||
|
|
b77f570c57 | ||
|
|
8ba64b7bcd | ||
|
|
e1dcfdab2e | ||
|
|
5c9b282361 | ||
|
|
cf5c7bd4c3 | ||
|
|
46adff882f | ||
|
|
87923e1e1d | ||
|
|
5bd544baf5 | ||
|
|
f8d410f858 | ||
|
|
cfa62e4b8b | ||
|
|
fa2820320e | ||
|
|
33070f6e06 | ||
|
|
f43c6462d8 |
@@ -624,10 +624,11 @@
|
||||
metabase.test/with-non-admin-groups-no-root-collection-perms hooks.common/do*
|
||||
metabase.test/with-persistence-enabled! hooks.metabase.model-persistence.test-util/with-persistence-enabled!
|
||||
metabase.test/with-prometheus-system! hooks.common/with-two-bindings
|
||||
metabase.test/with-random-premium-token! hooks.common/with-one-binding
|
||||
metabase.test/with-temp hooks.toucan2.tools.with-temp/with-temp
|
||||
metabase.test/with-temp-file hooks.metabase.test.util/with-temp-file
|
||||
metabase.test/with-temporary-setting-values hooks.metabase.test.util/with-temporary-setting-values
|
||||
metabase.users.api-test/with-temp-user-email! hooks.common/with-one-binding
|
||||
metabase.users.api-test/with-temp-user-email! hooks.common/with-one-binding
|
||||
metabase.util.i18n/deferred-trs hooks.metabase.util.i18n/deferred-trs
|
||||
metabase.util.i18n/deferred-tru hooks.metabase.util.i18n/deferred-tru
|
||||
metabase.util.i18n/trs hooks.metabase.util.i18n/trs
|
||||
|
||||
@@ -1361,9 +1361,11 @@
|
||||
users
|
||||
{:team "UX West"
|
||||
:api #{metabase.users.api
|
||||
metabase.users.core
|
||||
metabase.users.init
|
||||
metabase.users.models.user
|
||||
metabase.users.models.user-parameter-value}
|
||||
metabase.users.models.user-parameter-value
|
||||
metabase.users.schema}
|
||||
:uses #{analytics
|
||||
api
|
||||
appearance
|
||||
|
||||
@@ -232,7 +232,6 @@
|
||||
metabase.test.data/$ids [[:inner 0]]
|
||||
metabase.test.data/dataset [[:inner 0]]
|
||||
metabase.test.data/mbql-query [[:inner 0]]
|
||||
metabase.test.data/native-query [[:block 0]]
|
||||
metabase.test.data/query [[:block 1]]
|
||||
metabase.test.data/run-mbql-query [[:inner 0]]
|
||||
metabase.test.data/with-empty-h2-app-db! [[:block 0]]
|
||||
@@ -253,7 +252,6 @@
|
||||
metabase.test/format-rows-by [[:default]]
|
||||
metabase.test/let-url [[:block 1]]
|
||||
metabase.test/mbql-query [[:inner 0]]
|
||||
metabase.test/native-query [[:block 0]]
|
||||
metabase.test/query [[:block 1]]
|
||||
metabase.test/run-mbql-query [[:inner 0]]
|
||||
metabase.test/test-driver [[:inner 0]]
|
||||
|
||||
36
.github/actions/cleanup-python-runner/action.yml
vendored
Normal file
36
.github/actions/cleanup-python-runner/action.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Cleanup Python Runner
|
||||
description: Cleanup python-runner services and upload logs as artifacts
|
||||
|
||||
inputs:
|
||||
upload-logs:
|
||||
description: 'Whether to upload logs (typically on failure)'
|
||||
required: false
|
||||
default: 'false'
|
||||
logs-artifact-suffix:
|
||||
description: 'The unique name of the .zip artifact containing the logs'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Cleanup python-runner services
|
||||
if: always()
|
||||
run: |
|
||||
echo "Stopping python-runner services..."
|
||||
if [ -d "python-runner" ]; then
|
||||
cd python-runner
|
||||
make stop || echo "WARNING: Failed to stop python runner"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Upload python-runner logs
|
||||
if: ${{inputs.upload-logs == 'true' && inputs.logs-artifact-suffix != ''}}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: python-runner-logs-${{inputs.logs-artifact-suffix}}
|
||||
path: |
|
||||
python-runner.out.log
|
||||
python-runner.err.log
|
||||
retention-days: 1
|
||||
if-no-files-found: warn
|
||||
27
.github/actions/setup-python-runner/action.yml
vendored
Normal file
27
.github/actions/setup-python-runner/action.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Setup Python Runner
|
||||
description: Setup python-runner for tests
|
||||
inputs:
|
||||
metabase-automation-token:
|
||||
description: Token for accessing the python-runner repository
|
||||
required: true
|
||||
python-runner-version:
|
||||
description: Git tag or commit of the python-runner repository to checkout
|
||||
required: false
|
||||
default: 'v0.0.1'
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Clone and setup python-runner
|
||||
run: |
|
||||
echo "Cloning python-runner repository..."
|
||||
git clone --depth 1 --branch ${{ inputs.python-runner-version }} \
|
||||
https://${{ inputs.metabase-automation-token }}@github.com/metabase/python-runner-container.git python-runner
|
||||
|
||||
echo "Starting python-runner..."
|
||||
cd python-runner
|
||||
make run-ci 1> ../python-runner.out.log 2> ../python-runner.err.log
|
||||
make logs 1>> ../python-runner.out.log 2>> ../python-runner.err.log &
|
||||
cd ..
|
||||
shell: bash
|
||||
env:
|
||||
METABASE_AUTOMATION_USER_TOKEN: ${{ inputs.metabase-automation-token }}
|
||||
3
.github/scripts/setup-python-runner.sh
vendored
3
.github/scripts/setup-python-runner.sh
vendored
@@ -24,6 +24,7 @@ git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabas
|
||||
if [ "$SKIP_START" = false ]; then
|
||||
echo "Starting python-runner..."
|
||||
cd python-runner
|
||||
make run-ci
|
||||
make run-ci 1> ../python-runner.out.log 2> ../python-runner.err.log
|
||||
make logs 1>> ../python-runner.out.log 2>> ../python-runner.err.log &
|
||||
cd ..
|
||||
fi
|
||||
|
||||
138
.github/workflows/drivers.yml
vendored
138
.github/workflows/drivers.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
skip:
|
||||
type: boolean
|
||||
default: false
|
||||
python-runner-version:
|
||||
type: string
|
||||
default: 'v0.0.1'
|
||||
description: 'Git tag or commit of python-runner repository to use'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-drivers
|
||||
@@ -135,10 +139,12 @@ jobs:
|
||||
- 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
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
python-runner-version: ${{ inputs.python-runner-version }}
|
||||
- name: Test BigQuery Cloud SDK driver
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-bigquery-cloud-sdk-ee'
|
||||
@@ -154,6 +160,12 @@ jobs:
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
|
||||
|
||||
be-tests-clickhouse-ee:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -183,9 +195,10 @@ jobs:
|
||||
- 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
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
python-runner-version: ${{ inputs.python-runner-version }}
|
||||
- name: Add ClickHouse TLS instance to /etc/hosts
|
||||
run: |
|
||||
sudo echo "127.0.0.1 server.clickhouseconnect.test" | sudo tee -a /etc/hosts
|
||||
@@ -202,6 +215,7 @@ jobs:
|
||||
clickhouse_cluster_node2
|
||||
nginx
|
||||
- name: Test Clickhouse driver
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-clickhouse-ee'
|
||||
@@ -217,6 +231,12 @@ jobs:
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
|
||||
|
||||
be-tests-druid-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -377,10 +397,12 @@ jobs:
|
||||
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
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
python-runner-version: ${{ inputs.python-runner-version }}
|
||||
- name: Test H2 driver
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-h2'
|
||||
@@ -398,6 +420,12 @@ jobs:
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
|
||||
|
||||
be-tests-mysql-mariadb:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -431,7 +459,7 @@ jobs:
|
||||
- name: Driver Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
exclude-tag: ':mb/transforms-python-test'
|
||||
- name: Transforms Python Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
@@ -491,11 +519,13 @@ jobs:
|
||||
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
|
||||
if: ${{ matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
python-runner-version: ${{ inputs.python-runner-version }}
|
||||
- name: Test ${{ matrix.version.name }}
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
@@ -513,6 +543,12 @@ jobs:
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
|
||||
|
||||
be-tests-mongo:
|
||||
needs: determine-driver-skips
|
||||
@@ -556,10 +592,12 @@ jobs:
|
||||
- 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
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
python-runner-version: ${{ inputs.python-runner-version }}
|
||||
- name: Test ${{ matrix.version.name }}
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
@@ -575,6 +613,12 @@ jobs:
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
|
||||
|
||||
be-tests-mongo-ssl:
|
||||
needs: determine-driver-skips
|
||||
@@ -770,7 +814,7 @@ jobs:
|
||||
- name: Driver Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
exclude-tag: ':mb/transforms-python-test'
|
||||
- name: Transforms Python Tests
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
@@ -817,11 +861,13 @@ jobs:
|
||||
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
|
||||
if: ${{ matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
python-runner-version: ${{ inputs.python-runner-version }}
|
||||
- name: Test ${{ matrix.version.name }} (${{ matrix.job.name }})
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-${{ matrix.version.junit-name }}'
|
||||
@@ -839,6 +885,12 @@ jobs:
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
|
||||
|
||||
be-tests-presto-jdbc-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -949,10 +1001,12 @@ jobs:
|
||||
- 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
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
python-runner-version: ${{ inputs.python-runner-version }}
|
||||
- name: Test ${{ matrix.job.name }}
|
||||
id: test-driver
|
||||
if: ${{ !matrix.job.skip }}
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
@@ -970,6 +1024,12 @@ jobs:
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
|
||||
|
||||
be-tests-snowflake-ee:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -1011,10 +1071,12 @@ jobs:
|
||||
- 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
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
python-runner-version: ${{ inputs.python-runner-version }}
|
||||
- name: Test Snowflake driver
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-snowflake-ee'
|
||||
@@ -1030,6 +1092,12 @@ jobs:
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
|
||||
|
||||
be-tests-sparksql-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -1140,10 +1208,12 @@ jobs:
|
||||
- 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
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
python-runner-version: ${{ inputs.python-runner-version }}
|
||||
- name: Test ${{ matrix.version.name }}
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
@@ -1159,6 +1229,12 @@ jobs:
|
||||
aws-secret-access-key: ${{ secrets.AWS_TEST_RESULTS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: ${{ steps.test-driver.outcome == 'failure' }}
|
||||
logs-artifact-suffix: ${{ matrix.job.name }} ${{ matrix.version.name }}
|
||||
|
||||
# TODO(dpsutton, 2025-08-20): Re-enable starburst when we figure out why it randomly dies in CI
|
||||
# be-tests-starburst-ee:
|
||||
|
||||
18
.github/workflows/e2e-test.yml
vendored
18
.github/workflows/e2e-test.yml
vendored
@@ -28,6 +28,11 @@ on:
|
||||
split_index: # index of the chunk
|
||||
required: false
|
||||
type: number
|
||||
python-runner-version:
|
||||
type: string
|
||||
default: 'v0.0.1'
|
||||
description: 'Git tag or commit of python-runner repository to use'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
@@ -65,9 +70,10 @@ jobs:
|
||||
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
|
||||
uses: ./.github/actions/setup-python-runner
|
||||
with:
|
||||
metabase-automation-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
python-runner-version: ${{ inputs.python-runner-version }}
|
||||
|
||||
- name: Prepare Docker containers
|
||||
uses: ./.github/actions/e2e-prepare-containers
|
||||
@@ -246,3 +252,9 @@ jobs:
|
||||
const summary = formatSummary(results);
|
||||
|
||||
await core.summary.addRaw(summary).write();
|
||||
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
with:
|
||||
upload-logs: false
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
@@ -29,7 +29,8 @@
|
||||
- Use the linter as a way to know that you are adhering to conventions in place in the codebase
|
||||
- **Lint Changes:** `./bin/mage kondo-updated HEAD`
|
||||
- **Format:** `./bin/mage cljfmt-files [path]`
|
||||
- **Test file:** `clojure -X:dev:test :only namespace/test-name`
|
||||
- **Run a test:** `./bin-mage run-tests namespace/test-name`
|
||||
- **Run all tests in a namespace:** `./bin-mage run-tests namespace`
|
||||
- **Check Code Readability** `./bin/mage -check-readable` with optional line-number
|
||||
- Run this after every change to Clojure code, only accept readable code
|
||||
- **Evaluating Clojure Code** `./bin/mage -repl '<code>'`
|
||||
|
||||
41
README.md
41
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
[Metabase](https://www.metabase.com) is the easy, open-source way for everyone in your company to ask questions and learn from data.
|
||||
|
||||

|
||||

|
||||
|
||||
[](https://github.com/metabase/metabase/releases)
|
||||
[](https://codecov.io/gh/metabase/metabase)
|
||||
@@ -12,28 +12,29 @@
|
||||
|
||||
The easiest way to get started with Metabase is to sign up for a free trial of [Metabase Cloud](https://store.metabase.com/checkout). You get support, backups, upgrades, an SMTP server, SSL certificate, SoC2 Type 2 security auditing, and more (plus your money goes toward improving Metabase). Check out our quick overview of [cloud vs self-hosting](https://www.metabase.com/docs/latest/cloud/cloud-vs-self-hosting). If you need to, you can always switch to [self-hosting](https://www.metabase.com/docs/latest/installation-and-operation/installing-metabase) Metabase at any time (or vice versa).
|
||||
|
||||
## Features
|
||||
## Key Features
|
||||
|
||||
- [Set up in five minutes](https://www.metabase.com/docs/latest/setting-up-metabase.html) (we're not kidding).
|
||||
- [Set up in five minutes](https://www.metabase.com/docs/latest/configuring-metabase/setting-up-metabase) (we're not kidding).
|
||||
- Let anyone on your team [ask questions](https://www.metabase.com/docs/latest/questions/introduction) without knowing SQL.
|
||||
- Use the [SQL editor](https://www.metabase.com/docs/latest/questions/native-editor/writing-sql) for more complex queries.
|
||||
- Build handsome, interactive [dashboards](https://www.metabase.com/docs/latest/users-guide/07-dashboards.html) with filters, auto-refresh, fullscreen, and custom click behavior.
|
||||
- Build handsome, interactive [dashboards](https://www.metabase.com/docs/latest/dashboards/introduction) with filters, auto-refresh, fullscreen, and custom click behavior.
|
||||
- Create [models](https://www.metabase.com/learn/metabase-basics/getting-started/models) that clean up, annotate, and/or combine raw tables.
|
||||
- Define canonical [segments and metrics](https://www.metabase.com/docs/latest/administration-guide/07-segments-and-metrics.html) for your team to use.
|
||||
- Send data to Slack or email on a schedule with [dashboard subscriptions](https://www.metabase.com/docs/latest/users-guide/dashboard-subscriptions).
|
||||
- Set up [alerts](https://www.metabase.com/docs/latest/users-guide/15-alerts.html) to have Metabase notify you when your data changes.
|
||||
- [Embed charts and dashboards](https://www.metabase.com/docs/latest/administration-guide/13-embedding.html) in your app, or even [your entire Metabase](https://www.metabase.com/docs/latest/enterprise-guide/full-app-embedding.html).
|
||||
- Define canonical [segments and metrics](https://www.metabase.com/docs/latest/data-modeling/metrics) for your team to use.
|
||||
- Send data to Slack or email on a schedule with [dashboard subscriptions](https://www.metabase.com/docs/latest/dashboards/subscriptions).
|
||||
- Set up [alerts](https://www.metabase.com/docs/latest/questions/alerts) to have Metabase notify you when your data changes.
|
||||
- [Embed charts and dashboards](https://www.metabase.com/docs/latest/embedding/introduction) in your app, or even [your entire Metabase](https://www.metabase.com/docs/latest/embedding/interactive-embedding).
|
||||
- Build with the [Embedded Analytics SDK for React](https://www.metabase.com/docs/latest/embedding/sdk/introduction) to embed standalone components with custom styling that matches your app's design.
|
||||
|
||||
Take a [tour of Metabase](https://www.metabase.com/learn/getting-started/tour-of-metabase).
|
||||
Take a [tour of Metabase](https://www.metabase.com/learn/metabase-basics/overview/tour-of-metabase).
|
||||
|
||||
## Supported databases
|
||||
|
||||
- [Officially supported databases](./docs/databases/connecting.md#connecting-to-supported-databases)
|
||||
- [Community drivers](./docs/developers-guide/partner-and-community-drivers.md)
|
||||
- [Community drivers](./docs/developers-guide/community-drivers.md)
|
||||
|
||||
## Installation
|
||||
|
||||
Metabase can be run just about anywhere. Check out our [Installation Guides](https://www.metabase.com/docs/latest/operations-guide/installing-metabase).
|
||||
Metabase can be run just about anywhere. Check out our [Installation Guides](https://www.metabase.com/docs/latest/installation-and-operation/installing-metabase).
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -43,7 +44,7 @@ In order to spin up a development environment, you need to start the front end a
|
||||
|
||||
### Frontend quick setup
|
||||
|
||||
The following command will install the Javascript dependencies:
|
||||
The following command will install the JavaScript dependencies:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
@@ -63,7 +64,7 @@ yarn build-hot
|
||||
|
||||
### Backend quick setup
|
||||
|
||||
In order to run the backend, you'll need to build the drivers first, and then start the backend:
|
||||
To run the backend, you'll need to build the drivers first, and then start the backend:
|
||||
|
||||
```bash
|
||||
./bin/build-drivers.sh
|
||||
@@ -78,13 +79,13 @@ We want Metabase to be available in as many languages as possible. See which tra
|
||||
|
||||
## Extending Metabase
|
||||
|
||||
Hit our Query API from Javascript to integrate analytics. Metabase enables your application to:
|
||||
Hit our Query API from JavaScript to integrate analytics. Metabase enables your application to:
|
||||
|
||||
- Build moderation interfaces.
|
||||
- Export subsets of your users to third party marketing automation software.
|
||||
- Provide a custom customer lookup application for the people in your company.
|
||||
|
||||
Check out our guide, [Working with the Metabase API](https://www.metabase.com/learn/administration/metabase-api).
|
||||
Check out our guide, [Working with the Metabase API](https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/metabase-api).
|
||||
|
||||
## Security Disclosure
|
||||
|
||||
@@ -98,6 +99,14 @@ See [LICENSE.txt](./LICENSE.txt) for details.
|
||||
|
||||
Unless otherwise noted, all files © 2025 Metabase, Inc.
|
||||
|
||||
## [Metabase Experts](https://www.metabase.com/partners/)
|
||||
## Community
|
||||
|
||||
- [Discourse](https://discourse.metabase.com/)
|
||||
- [Twitter](https://x.com/metabase)
|
||||
- [LinkedIn](https://www.linkedin.com/company/metabase/)
|
||||
- [YouTube](https://www.youtube.com/@metabasedata)
|
||||
- [Reddit](https://www.reddit.com/r/Metabase/)
|
||||
|
||||
## Metabase Experts
|
||||
|
||||
If you’d like more technical resources to set up your data stack with Metabase, connect with a [Metabase Expert](https://www.metabase.com/partners/?utm_source=readme&utm_medium=metabase-expetrs&utm_campaign=readme).
|
||||
|
||||
23
bb.edn
23
bb.edn
@@ -1,4 +1,4 @@
|
||||
{:min-bb-version "1.12.197"
|
||||
{:min-bb-version "1.12.208"
|
||||
;; we put path as bin, and everything is in the ./mage subdirectory,
|
||||
;; so the namespaces are mage.cli, mage.format, etc.
|
||||
:paths ["mage" "test" "bin/lint-migrations-file/src"]
|
||||
@@ -161,7 +161,7 @@
|
||||
:task (stats-repl/connect (cli/parse! (current-task)))}
|
||||
|
||||
notify
|
||||
{:doc "Send notification for a comment (that already exists"
|
||||
{:doc "Send notification for a comment (that already exists)"
|
||||
:requires [[mage.be-dev :refer [nrepl-eval]]]
|
||||
:options [["-c" "--comment-id ID" "Notify about this comment id"]
|
||||
["-p" "--port PORT" "Port to use for the task, defaults to value in .nrepl-port"]]
|
||||
@@ -172,6 +172,25 @@
|
||||
(nrepl-eval "metabase-enterprise.comments.core" code (:port opts))
|
||||
(nrepl-eval "metabase-enterprise.comments.core" code)))}
|
||||
|
||||
run-tests
|
||||
{:doc "Run a tests (or a single test) against nrepl server"
|
||||
:requires [[clojure.string :as str]
|
||||
[mage.be-dev :refer [nrepl-eval]]]
|
||||
:arg-schema [:tuple [:string {:name "NS/TEST" :desc "or just NS"}]]
|
||||
:options [["-p" "--port PORT" "Port to use for the task, defaults to value in .nrepl-port"]]
|
||||
:task (let [{:keys [options]
|
||||
[test] :arguments} (cli/parse! (current-task))
|
||||
test (if (or (str/starts-with? test "test/")
|
||||
(str/starts-with? test "enterprise/"))
|
||||
;; paths should come in as strings
|
||||
(format "\"%s\"" test)
|
||||
;; ns or var should be a symbol
|
||||
(str "'" test))
|
||||
code (format
|
||||
"(do ((requiring-resolve 'dev.reload/reload!)) (find-and-run-tests-repl {:only %s}))"
|
||||
test)]
|
||||
(nrepl-eval "mb.hawk.core" (str code) (:port options)))}
|
||||
|
||||
ls {:doc "List all mage public and private tasks"
|
||||
:examples [["./bin/mage ls" "List all tasks"]]
|
||||
:requires [[mage.util :as u]]
|
||||
|
||||
151
dev/src/dev/reload.clj
Normal file
151
dev/src/dev/reload.clj
Normal file
@@ -0,0 +1,151 @@
|
||||
(ns dev.reload
|
||||
(:require
|
||||
[clojure.java.shell :as shell]
|
||||
[clojure.string :as str]
|
||||
[metabase.util :as u])
|
||||
(:import
|
||||
(java.io File)
|
||||
(java.nio.file Files LinkOption Paths)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
#_:clj-kondo/ignore
|
||||
(defonce *reload-timestamps (atom {}))
|
||||
|
||||
(defn system-classpath
|
||||
"Returns a sequence of File paths from the 'java.class.path' system
|
||||
property."
|
||||
[]
|
||||
(into [] (comp (filter #(not (str/ends-with? % ".jar")))
|
||||
(map #(File. ^String %))
|
||||
(filter #(.isDirectory ^File %))
|
||||
(map #(.getCanonicalPath ^File %)))
|
||||
(.split (System/getProperty "java.class.path")
|
||||
(System/getProperty "path.separator"))))
|
||||
|
||||
(defn find-classpath-root
|
||||
"Finds the classpath directory that contains the given file path.
|
||||
Returns the portion of the path to strip, or nil if not found."
|
||||
[classpath file-path]
|
||||
(let [canonical-file (try
|
||||
(.getCanonicalPath (File. ^String file-path))
|
||||
(catch Exception _ file-path))]
|
||||
(->> classpath
|
||||
(filter #(str/starts-with? canonical-file %))
|
||||
(sort-by count >) ; Longest match first
|
||||
first)))
|
||||
|
||||
(defn git-changed-files
|
||||
"Returns a set of changed/added/untracked files from git status."
|
||||
[]
|
||||
(let [{:keys [exit out]} (shell/sh "git" "status" "--porcelain")]
|
||||
(if (and (zero? exit)
|
||||
(not (str/blank? out)))
|
||||
(->> (str/split-lines out)
|
||||
(map #(str/trim (subs % 3))) ; Remove status prefix (e.g., "M ", "A ", "??")
|
||||
(filter #(str/ends-with? % ".clj"))
|
||||
set)
|
||||
#{})))
|
||||
|
||||
(defn file-last-modified
|
||||
"Returns the last modified time of a file in milliseconds."
|
||||
[file-path]
|
||||
(try
|
||||
(-> (Paths/get file-path (into-array String []))
|
||||
(Files/getLastModifiedTime (into-array LinkOption []))
|
||||
(.toMillis))
|
||||
(catch Exception _ nil)))
|
||||
|
||||
(defn relpath->ns
|
||||
"foo/bar_baz.clj -> foo.bar-baz"
|
||||
[relpath]
|
||||
(-> relpath
|
||||
(str/replace #"\.clj$" "")
|
||||
(str/replace #"/" ".")
|
||||
(str/replace #"_" "-")
|
||||
symbol))
|
||||
|
||||
(defn file-path->namespace
|
||||
"Converts a file path to a namespace symbol using classpath information.
|
||||
Example: enterprise/backend/src/foo/bar/baz.clj -> foo.bar.baz"
|
||||
[file-path classpath]
|
||||
(when (and file-path (re-find #".cljc?$" file-path))
|
||||
(when-let [root (find-classpath-root classpath file-path)]
|
||||
(let [canonical-file (try
|
||||
(.getCanonicalPath (File. ^String file-path))
|
||||
(catch Exception _ file-path))
|
||||
relative-path (subs canonical-file (inc (count root)))]
|
||||
(relpath->ns relative-path)))))
|
||||
|
||||
(defn loaded-namespaces
|
||||
"Returns a set of all currently loaded namespace symbols."
|
||||
[]
|
||||
(set (map ns-name (all-ns))))
|
||||
|
||||
(defn needs-reload?
|
||||
"Checks if a file needs reloading based on its last modified time."
|
||||
[file-path ns-sym]
|
||||
(let [last-reload (get @*reload-timestamps ns-sym)
|
||||
last-modified (file-last-modified file-path)]
|
||||
(or (nil? last-reload)
|
||||
(nil? last-modified)
|
||||
(> last-modified last-reload))))
|
||||
|
||||
(defn changed-namespaces []
|
||||
(let [changed-files (git-changed-files)
|
||||
loaded-ns (loaded-namespaces)
|
||||
classpath-dirs (system-classpath)
|
||||
candidates (->> changed-files
|
||||
(map (fn [f] {:file f :ns (file-path->namespace f classpath-dirs)}))
|
||||
(filter :ns)
|
||||
(filter #(loaded-ns (:ns %))))
|
||||
to-reload (filter #(needs-reload? (:file %) (:ns %)) candidates)
|
||||
skipped (remove #(needs-reload? (:file %) (:ns %)) candidates)]
|
||||
{:to-reload to-reload
|
||||
:skipped skipped}))
|
||||
|
||||
(defn reload-namespaces!
|
||||
[to-reload]
|
||||
(->> (for [{:keys [_file ns]} to-reload]
|
||||
(try
|
||||
(require ns :reload)
|
||||
(swap! *reload-timestamps assoc ns (System/currentTimeMillis))
|
||||
[:reloaded ns]
|
||||
(catch Exception e
|
||||
[:failed {:ns ns :error (.getMessage e)}])))
|
||||
(u/group-by first second)))
|
||||
|
||||
(defn reload!
|
||||
"Reloads all changed files from git that are already loaded as namespaces
|
||||
and have been modified since last reload.
|
||||
Returns a map with :reloaded, :skipped, and :failed keys."
|
||||
[]
|
||||
(let [{:keys [to-reload
|
||||
skipped]} (changed-namespaces)
|
||||
_ (println (format "Reloading %s namespaces (%s skipped):" (count to-reload) (count skipped)))
|
||||
{:keys [reloaded
|
||||
failed]
|
||||
:as res} (reload-namespaces! to-reload)]
|
||||
(doseq [ns reloaded]
|
||||
(println " ✓" ns))
|
||||
(doseq [{:keys [ns error]} failed]
|
||||
(println " ✗" ns ":" error))
|
||||
(assoc res :skipped skipped)))
|
||||
|
||||
(defn clear-reload-history!
|
||||
"Clears the reload timestamp history, forcing all files to reload next time."
|
||||
[]
|
||||
(reset! *reload-timestamps {})
|
||||
(println "Reload history cleared."))
|
||||
|
||||
(defn show-classpath
|
||||
"Shows all classpath directories for debugging."
|
||||
[]
|
||||
(println "\n=== Classpath Directories ===")
|
||||
(run! #(println (.getCanonicalPath ^File %)) (sort (system-classpath))))
|
||||
|
||||
(comment
|
||||
(show-classpath) ; to see what's in your classpath
|
||||
(clear-reload-history!) ; if you want to force reload everything
|
||||
(changed-namespaces)
|
||||
(reload!))
|
||||
@@ -53,7 +53,7 @@ Some tips:
|
||||
Hover over Metabot's response to:
|
||||
|
||||
- Copy the response.
|
||||
- Give thumbs-up/thumbs-down feedback on responses. If you downvote a response, you can optionally contribute a bug report to help us improve Metabot.
|
||||
- Give thumbs-up/thumbs-down [feedback on responses](#giving-feedback-on-metabot-responses). If you upvote a response, you can optionally add some feedback. If you downvote a response, you can optionally contribute a bug report to help us improve Metabot.
|
||||
- Re-run the prompt with Metabot. This is useful if you've updated the chart or just want to have Metabot take another pass (since AI responses aren't deterministic, Metabot may give a different response on another run).
|
||||
|
||||
## Analyze charts with Metabot
|
||||
@@ -68,7 +68,7 @@ You can also ask Metabot to tell you about specific tables in your database.
|
||||
|
||||
## How Metabot uses the query builder
|
||||
|
||||
When you ask Metabot to create a chart from natural language, it first looks for existing questions that might answer your request. If it finds a relevant question, it'll point you to that instead of creating something new. Otherwise, Metabot will use the [query builder](../questions/query-builder/editor.md) to create a new chart for you.
|
||||
When you ask Metabot to create a chart from natural language, it first looks for existing questions that might answer your request. If it finds a relevant question, it'll point you to that instead of creating something new. Otherwise, Metabot will use the [query builder](../questions/query-builder/editor.md) to create a new chart for you.
|
||||
|
||||
Keep in mind that Metabot is still learning the query builder. Metabot can only handle basic query builder operations, and it lacks access to the library of [custom expressions](../questions/query-builder/expressions-list.md). Metabot is also limited to single-level aggregation and grouping, so if you need more complex analysis, you can take over and refine the query yourself, or switch to the SQL editor.
|
||||
|
||||
@@ -98,3 +98,19 @@ When you get an error in a SQL query, you can click the **Have Metabot fix it**
|
||||
If Metabot creates a query or takes you to a new item but you want to return to the previous screen, you can navigate using your browser's back button.
|
||||
|
||||
You can also save any chart that Metabot creates to a dashboard or collection.
|
||||
|
||||
## Giving feedback on Metabot responses
|
||||
|
||||
When you hover over Metabot's responses, you'll see options to give feedback.
|
||||
|
||||
Thumbs up are just as helpful as thumbs down. If you give a thumbs down, you can optionally provide more details about the issue and set an issue type:
|
||||
|
||||
- UI bug
|
||||
- Took incorrect actions
|
||||
- Overall refusal
|
||||
- Did not follow request
|
||||
- Not factually correct
|
||||
- Incomplete response
|
||||
- Other
|
||||
|
||||
> When you submit feedback, the form you send may contain sensitive data from your conversation.
|
||||
|
||||
@@ -28,7 +28,7 @@ When embedding Metabot in your app, you can select a collection for Metabot:
|
||||
1. Go to **Settings** > **Admin settings** > **AI**.
|
||||
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.
|
||||
4. 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. To give Metabot access to all collections, you can set the collection to the root collection, called "Our Analytics" (the default).
|
||||
|
||||
@@ -91,6 +91,16 @@ Under the hood, Metabase powers Metabot with a variety of generative models. For
|
||||
|
||||
To get the best results, we (the Metabase team) use internal benchmarks to determine which AI models Metabot should use for different tasks. And we are constantly iterating on performance, so Metabot will continue to improve over time.
|
||||
|
||||
## We don't collect or store the prompts you send to Metabot
|
||||
## Unless you submit feedback, we don't collect or store the prompts you send to Metabot
|
||||
|
||||
We've intentionally limited what Metabot can do. Metabot lacks access to API keys, and it can't create assets, write data, or send data outside of your Metabase. Your questions and conversations remain private to your Metabase. We do collect some metadata to gauge and improve usage, but we don't train Metabot on your prompts or your data—because we can't see them!
|
||||
We've intentionally limited what Metabot can do. Metabot lacks access to API keys, and it can't create assets, write data, or send your data outside of your Metabase. Your questions and conversations remain private to your Metabase (unless you [submit feedback](./metabot.md#giving-feedback-on-metabot-responses)). We do collect some metadata to gauge and improve usage.
|
||||
|
||||
### What Metabot can see
|
||||
|
||||
Metabot has access to your Metabase metadata and some data values to help answer your questions:
|
||||
|
||||
- **Table, Question, Model, Dashboard, and Metric metadata**: Metabot can see the structure and configuration of your content
|
||||
- **Sample field values**: When you ask questions like "Filter everyone from Wisconsin," Metabot might check the values in the state field to understand how the data is stored (like "WI" vs "Wisconsin"). See [syncs](../databases/sync-scan.md).
|
||||
- **Timeseries data**: For chart analysis, Metabot might see the timeseries data used to draw certain visualizations, depending on the chart type.
|
||||
|
||||
When you [submit feedback](./metabot.md#giving-feedback-on-metabot-responses), however, the form you send may contain sensitive data from your conversation.
|
||||
|
||||
@@ -14971,6 +14971,35 @@ redirect_from:
|
||||
"tags" : [ "/api/ee/metabot-v3/document" ]
|
||||
}
|
||||
},
|
||||
"/api/ee/metabot-v3/feedback" : {
|
||||
"post" : {
|
||||
"summary" : "POST /api/ee/metabot-v3/feedback",
|
||||
"description" : "Proxy Metabot feedback to Harbormaster, adding the premium embedding token.",
|
||||
"parameters" : [ ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
"description" : "Successful response"
|
||||
},
|
||||
"4XX" : {
|
||||
"description" : "Client error response"
|
||||
},
|
||||
"5XX" : {
|
||||
"description" : "Server error response"
|
||||
}
|
||||
},
|
||||
"requestBody" : {
|
||||
"content" : {
|
||||
"application/json" : {
|
||||
"schema" : {
|
||||
"type" : "object",
|
||||
"properties" : { }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags" : [ "/api/ee/metabot-v3" ]
|
||||
}
|
||||
},
|
||||
"/api/ee/metabot-v3/metabot/" : {
|
||||
"get" : {
|
||||
"summary" : "GET /api/ee/metabot-v3/metabot/",
|
||||
@@ -15821,6 +15850,16 @@ redirect_from:
|
||||
},
|
||||
"value" : {
|
||||
"anyOf" : [ {
|
||||
"type" : "object",
|
||||
"additionalProperties" : {
|
||||
"anyOf" : [ {
|
||||
"type" : "string",
|
||||
"minLength" : 1
|
||||
}, {
|
||||
"type" : "boolean"
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"type" : "string",
|
||||
"minLength" : 1
|
||||
}, {
|
||||
@@ -16176,7 +16215,18 @@ redirect_from:
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "transform_tag_ids",
|
||||
"name" : "last_run_statuses",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "started", "succeeded", "failed", "timeout" ]
|
||||
}
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "tag_ids",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "array",
|
||||
@@ -16498,7 +16548,38 @@ redirect_from:
|
||||
"get" : {
|
||||
"summary" : "GET /api/ee/transform/",
|
||||
"description" : "Get a list of transforms.",
|
||||
"parameters" : [ ],
|
||||
"parameters" : [ {
|
||||
"in" : "query",
|
||||
"name" : "last_run_start_time",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "string",
|
||||
"minLength" : 1
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "last_run_statuses",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "started", "succeeded", "failed", "timeout" ]
|
||||
}
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "tag_ids",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"description" : "value must be an integer greater or equal to than zero.",
|
||||
"type" : "integer",
|
||||
"minimum" : 0
|
||||
}
|
||||
}
|
||||
} ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
"description" : "Successful response"
|
||||
|
||||
@@ -122,11 +122,12 @@ If you have databases connected to Metabase that are set to **Use default** poli
|
||||
To set a caching policy for a dashboard, you must have [curate access](../permissions/collections.md#curate-access) to the dashboard's collection.
|
||||
|
||||
1. Go to your dashboard.
|
||||
2. Click on the **info** icon.
|
||||
3. Click **Caching policy**.
|
||||
4. Select the [caching invalidation policy](#cache-invalidation-policies).
|
||||
5. Optional: turn on [refresh cache automatically](#refresh-cache-automatically).
|
||||
6. Save your changes.
|
||||
2. Click on the **three dots** icon in the top-right corner of the dashboard and choose **Edit settings**.
|
||||
3. By default, each question will use the database default caching settings. Click the current caching policy to change it.
|
||||
4. Select the new [caching policy](#cache-invalidation-policies).
|
||||
5. Optional: turn on [refresh cache automatically](#refresh-cache-automatically)
|
||||
6. Optional: to clear the cache for all questions on a dashboard, click **Clear cache for this dashboard** at the bottom of the setting sidebar.
|
||||
7. Save your changes.
|
||||
|
||||
### Question caching policy
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ config:
|
||||
ldap-security: none
|
||||
ldap-sync-user-attributes: true
|
||||
ldap-sync-user-attributes-blacklist: userPassword,dn,distinguishedName
|
||||
ldap-timeout-seconds: 15.0
|
||||
ldap-user-base: null
|
||||
ldap-user-filter: (&(objectClass=inetOrgPerson)(|(uid={login})(mail={login})))
|
||||
ldap-user-provisioning-enabled: true
|
||||
|
||||
@@ -1058,6 +1058,14 @@ Should we sync user attributes when someone logs in via LDAP?
|
||||
|
||||
Comma-separated list of user attributes to skip syncing for LDAP users.
|
||||
|
||||
### `MB_LDAP_TIMEOUT_SECONDS`
|
||||
|
||||
- Type: double
|
||||
- Default: `15.0`
|
||||
- [Configuration file name](./config-file.md): `ldap-timeout-seconds`
|
||||
|
||||
Maximum time, in seconds, to wait for LDAP server before falling back to local authentication.
|
||||
|
||||
### `MB_LDAP_USER_BASE`
|
||||
|
||||
- Type: string
|
||||
|
||||
@@ -19,7 +19,7 @@ To manage upload settings, admins can hit cmd/ctrl + K and search for "Settings
|
||||
- [PostgreSQL](../databases/connections/postgresql.md)
|
||||
- [MySQL](../databases/connections/mysql.md)
|
||||
- [Redshift](../databases/connections/redshift.md)
|
||||
- [ClickHouse](../databases/connections/clickhouse.md)
|
||||
- [ClickHouse](../databases/connections/clickhouse.md) (only supported on ClickHouse Cloud)
|
||||
|
||||
## Setting up uploads
|
||||
|
||||
|
||||
BIN
docs/documents/images/document-layout.png
Normal file
BIN
docs/documents/images/document-layout.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
@@ -42,9 +42,9 @@ If however, you delete the document's copy, that copy is permanently deleted. It
|
||||
|
||||
### Charts generated by Metabot only exist in the document
|
||||
|
||||
If Metabot generates a chart, Metabase saves that question to the document itself. If you delete it, it's gone forever. You won't be able to recover the question from the trash.
|
||||
If [Metabot](../ai/metabot.md) generates a chart, Metabase saves that question to the document itself. If you delete it, it's gone forever. You won't be able to recover the question from the trash.
|
||||
|
||||
### Editing charts
|
||||
## Editing charts
|
||||
|
||||
To edit a chart, click the **three-dot** menu in the chart's upper right. You can:
|
||||
|
||||
@@ -54,6 +54,22 @@ To edit a chart, click the **three-dot** menu in the chart's upper right. You ca
|
||||
|
||||
Edits only affect the chart in the document. If you added the chart from a collection, Metabase will have created a copy of the chart. Any changes made to the chart in the document won't affect the original question.
|
||||
|
||||
## Arranging charts
|
||||
|
||||

|
||||
|
||||
To rearrange charts, simply click and hold on the chart and drag it to where you want it in the document.
|
||||
|
||||
You can put up to three charts side by side (more than three charts side-by-side gets crowded). To resize charts, you can click on the edges to adjust their size.
|
||||
|
||||
When there are multiple charts in the same row, you can resize them horizontally by clicking between the charts and sliding left or right to resize.
|
||||
|
||||
To resize a chart's height, click the bottom of the chart and resize the chart up or down.
|
||||
|
||||
For now, you can only put charts side-by-side (not text elements).
|
||||
|
||||
After arranging your charts, save your changes!
|
||||
|
||||
## Formatting text
|
||||
|
||||
Documents support Markdown syntax for:
|
||||
@@ -71,9 +87,13 @@ If you paste a link into a document, Metabase will turn it into a clickable link
|
||||
|
||||
## Commenting on documents
|
||||
|
||||
To comment on a document, click the chat bubble next to the section you want to comment on.
|
||||
To comment on a text element, click the chat bubble next to the section. You can only comment on whole sections; you can't highlight text to comment on.
|
||||
|
||||
You can comment on whole sections like charts, headings, or text blocks. You can't highlight portions of text to comment on.
|
||||
To comment on a chart:
|
||||
|
||||
1. Hover over the chart.
|
||||
2. Click the three-dot menu.
|
||||
3. Select **Comment**.
|
||||
|
||||
You can:
|
||||
|
||||
@@ -84,7 +104,9 @@ You can:
|
||||
|
||||
### Comment notifications
|
||||
|
||||
If someone mentions you in a thread, or responds to a comment in a thread you're in, Metabase will send you an email to let you know. Currently, there's no central place to view all of your threads and mentions, and no way to disable email notifications for comments.
|
||||
If someone mentions you in a thread, or responds to a comment in a thread you're in, Metabase will send you an email to let you know.
|
||||
|
||||
Currently, there's no central place to view all of your threads and mentions, and no way to disable email notifications for comments.
|
||||
|
||||
### Deleting comments
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ summary: Getting started with Embedded Analytics JS for embedding Metabase entit
|
||||
|
||||
Embedded analytics JS lets you embed Metabase entities like questions, dashboards, or even the query builder into your own application using customizable components.
|
||||
|
||||
{% include shared/in-page-promo-embedding-workshop.html %}
|
||||
|
||||
Embedded Analytics JS is a JavaScript library built on top of Metabase's [Embedded Analytics React SDK](./sdk/introduction.md). But it does not require using React or setting up full SDK embedding.
|
||||
Unlike with [interactive embedding](./interactive-embedding.md), where you embed the entire Metabase app in an iframe, Embedded Analytics JS lets you choose from a set of predefined components like a single chart, a dashboard with optional drill-through, or query builder, and customize those components.
|
||||
|
||||
|
||||
@@ -11,12 +11,10 @@ redirect_from:
|
||||
|
||||
{% include shared/in-page-promo-embedding-workshop.html %}
|
||||
|
||||
**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).
|
||||
**Interactive embedding** lets you embed the entire Metabase app in an iframe. Interactive embedding integrates your [permissions](../permissions/introduction.md) and [SSO](../people-and-groups/start.md#authentication) to give people the right level of access to [query](../questions/query-builder/editor.md) and [drill-down](https://www.metabase.com/learn/metabase-basics/querying-and-dashboards/questions/drill-through) into your data.
|
||||
|
||||
> 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
|
||||
|
||||
To get a feel for what you can do with interactive embedding, check out our [interactive embedding demo](https://www.metabase.com/embedding-demo).
|
||||
|
||||
@@ -48,7 +48,6 @@ If you'd like to share your data with the good people of the internet, admins ca
|
||||
| [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 | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
@@ -56,7 +55,7 @@ If you'd like to share your data with the good people of the internet, admins ca
|
||||
|
||||
\* 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.
|
||||
\*\* Requires a [Pro and Enterprise](https://www.metabase.com/pricing/) plan for any embedding type.
|
||||
|
||||
### Embedded analytics SDK vs JS
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ You can check out a [demo of the AI chat component](https://embedded-analytics-s
|
||||
```typescript
|
||||
{% include_file "{{ dirname }}/snippets/questions/ai-question.tsx" %}
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
- [Component](./api/MetabotQuestion.html)
|
||||
- [Props](./api/MetabotQuestionProps.html)
|
||||
|
||||
## Setting up AI chat
|
||||
|
||||
@@ -40,3 +42,11 @@ To configure your embedded AI chat in your Metabase:
|
||||
When embedding the Metabot component in your app, you should specify a collection that embedded Metabot is allowed to use for creating queries. Embedded Metabot will only have access to that collection.
|
||||
|
||||
For tips and more, see [Metabot settings](../../ai/settings.md).
|
||||
|
||||
## Layout
|
||||
|
||||
Use the `layout` prop to specify which layout to use for the Metabot component:
|
||||
|
||||
- `auto` (default): Metabot uses the `stacked` layout on mobile screens, and a `sidebar` layout on larger screens.
|
||||
- `stacked`: the question visualization stacks on top of the chat interface.
|
||||
- `sidebar`: the question visualization appears to the left of the chat interface, which is on a sidebar on the right.
|
||||
|
||||
@@ -50,6 +50,8 @@ Metabase will only export the following entities:
|
||||
- Collections (but personal collections don't get exported unless explicitly specified them through [export options](#customize-what-gets-exported))
|
||||
- Dashboards
|
||||
- Saved questions
|
||||
- Transforms (including jobs)
|
||||
- Documents (without comments)
|
||||
- Actions
|
||||
- Models
|
||||
- Metrics
|
||||
@@ -61,7 +63,7 @@ Metabase will only export the following entities:
|
||||
- Events and timelines
|
||||
- Database connection strings (only if specified through [export options](#customize-what-gets-exported))
|
||||
|
||||
All other entities—including users, groups, permissions, alerts, subscriptions—won't get exported.
|
||||
All other entities—including users, groups, permissions, alerts, subscriptions, document comments—won't get exported.
|
||||
|
||||
Metabase will export its artifacts to a directory of YAML files. The export includes:
|
||||
|
||||
|
||||
BIN
docs/questions/images/pin-type.png
Normal file
BIN
docs/questions/images/pin-type.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -21,7 +21,7 @@ When you select the **Map** visualization setting, Metabase will automatically t
|
||||
|
||||
## Pin map
|
||||
|
||||
Pin maps display pins for individual data points on the map. They work best for displaying unaggregated geographical data.
|
||||
Pin maps display markers or tiles for individual data points on the map. They work best for displaying small amounts of unaggregated geographical data.
|
||||
|
||||
### Pin map data shape
|
||||
|
||||
@@ -30,7 +30,7 @@ To build a pin map, you need a query that returns a result that has latitude and
|
||||

|
||||

|
||||
|
||||
Note that the tooltips will only be displayed when the map is sufficiently zoomed in using [**Draw box to filter**](#draw-box-to-filter).
|
||||
Note that the tooltips will only be displayed when using the [marker pin type](#choose-pin-type).
|
||||
|
||||
### Create a pin map
|
||||
|
||||
@@ -42,9 +42,22 @@ To create a pin map:
|
||||
|
||||
Otherwise, click on the **Gear** icon to go to visualization settings, choose **Map type: Pin map**, and pick columns that contain latitude and longitude coordinates.
|
||||
|
||||
### Pin maps display 2000 pins by default
|
||||
### Choose pin type
|
||||
|
||||
By default, pin maps will display 2000 pins even if there are more rows in your query results (this limit is the same for every chart displaying unaggregated data in Metabase). You can use the environment variable [`MB_UNAGGREGATED_QUERY_ROW_LIMIT`](../../configuring-metabase/environment-variables.md#mb_unaggregated_query_row_limit) to increase the number of data points rendered on charts based on unaggregated queries, but keep in mind that this setting will affect all charts—not just the pin maps—and it might significantly slow down your Metabase and your browser.
|
||||
Metabase can display individual data points on a map as markers or tiles (small squares).
|
||||
|
||||

|
||||
|
||||
Maps default to markers for results fewer then 1,000 rows, and to tiles for results with 1,000 rows or more.
|
||||
|
||||
To change the pin type:
|
||||
|
||||
1. While viewing the map, click the **gear** icon in the bottom left.
|
||||
2. In the visualization settings, choose the pin type in the **Pin type** dropdown.
|
||||
- **Markers**. Maps display up to 1,000 markers. If you choose the marker pin type for results with more than 1,000 rows, Metabase will still only display 1,000 markers. Maps default to markers for results fewer then 1,000 rows.
|
||||
- **Tiles**. Maps display up to 2,000 tiles. Maps default to tiles for results greater than 1,000 rows. Tooltips will not be displayed for tiles.
|
||||
|
||||
By default, maps can't display more than 2,000 individual unaggregated data points, regardless of the pin type. This limit is the same for every chart displaying unaggregated data in Metabase. To increase the number of data points rendered on charts based on unaggregated queries, you can use the environment variable [`MB_UNAGGREGATED_QUERY_ROW_LIMIT`](../../configuring-metabase/environment-variables.md#mb_unaggregated_query_row_limit). This setting will affect data points on _all_ charts—not just the pin maps—so increasing the limit too much could significantly slow down your Metabase and your browswer.
|
||||
|
||||
If you need to display a large number of geographical data points, consider using a grid map to display the distribution instead.
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ if (!BASE_ENV.PREMIUM_EMBEDDING_TOKEN) {
|
||||
}
|
||||
|
||||
const BASE_SETUP_CONFIG = {
|
||||
"docker-up-command": "yarn docker:up",
|
||||
"docker-down-command": "yarn docker:down",
|
||||
"docker-up-command": "yarn docker:local-dist:up",
|
||||
"docker-down-command": "yarn docker:rm",
|
||||
"docker-env-example-path": ".env.docker.example",
|
||||
"docker-env-path": ".env.docker",
|
||||
defaultBranch: BRANCH_NAME,
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as path from "path";
|
||||
|
||||
import { E2E_TMP_FOLDER_PATH } from "../constants/e2e-tmp-folder-path";
|
||||
|
||||
const LOCAL_DIST_PATH = "./local-dist";
|
||||
const METABASE_FOLDER_PATH = "./metabase";
|
||||
|
||||
// See `docs/developers-guide/e2e-tests.md` for more info
|
||||
export function copyShoppyMetabaseAppDBDump(rootPath: string) {
|
||||
@@ -12,11 +12,11 @@ export function copyShoppyMetabaseAppDBDump(rootPath: string) {
|
||||
"db_dumps/shoppy_metabase_app_db_dump.sql",
|
||||
);
|
||||
|
||||
// The dump must be copied to the Shoppy's `local-dist` directory.
|
||||
// The dump must be copied to the Shoppy's `metabase` directory.
|
||||
// After that it will be used by the Shoppy's Docker container when it is built and started.
|
||||
const destinationPath = path.join(
|
||||
rootPath,
|
||||
LOCAL_DIST_PATH,
|
||||
METABASE_FOLDER_PATH,
|
||||
"metabase_dump.sql",
|
||||
);
|
||||
|
||||
|
||||
@@ -11,20 +11,29 @@ import {
|
||||
} from "e2e/support/helpers";
|
||||
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
|
||||
|
||||
export const getSignedJwtForUser = async (user = USERS.admin) => {
|
||||
export const getSignedJwtForUser = async ({
|
||||
user = USERS.admin,
|
||||
expiredInSeconds = 60 * 10, // 10 minutes expiration,
|
||||
}: {
|
||||
user?: { email: string; first_name: string; last_name: string };
|
||||
expiredInSeconds?: number;
|
||||
}) => {
|
||||
const secret = new TextEncoder().encode(JWT_SHARED_SECRET);
|
||||
|
||||
return new jose.SignJWT({
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
exp: Math.round(Date.now() / 1000) + 60 * 10, // 10 minutes expiration
|
||||
exp: Math.round(Date.now() / 1000) + expiredInSeconds,
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.sign(secret);
|
||||
};
|
||||
|
||||
export const mockAuthProviderAndJwtSignIn = (user = USERS.admin) => {
|
||||
export const mockAuthProviderAndJwtSignIn = (
|
||||
user = USERS.admin,
|
||||
{ jwt }: { jwt?: string } = {},
|
||||
) => {
|
||||
cy.intercept("GET", `${AUTH_PROVIDER_URL}**`, async (req) => {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
@@ -39,7 +48,7 @@ export const mockAuthProviderAndJwtSignIn = (user = USERS.admin) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = await getSignedJwtForUser(user);
|
||||
jwt = jwt || (await getSignedJwtForUser({ user }));
|
||||
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
EditableDashboard,
|
||||
InteractiveDashboard,
|
||||
InteractiveQuestion,
|
||||
} from "@metabase/embedding-sdk-react";
|
||||
|
||||
const { H } = cy;
|
||||
@@ -26,6 +27,30 @@ describe("scenarios > embedding-sdk > dashboard-click-behavior", () => {
|
||||
semantic_type: "type/URL",
|
||||
});
|
||||
|
||||
// Create a question with custom column that returns a url as a string
|
||||
H.createQuestion({
|
||||
name: "Orders with URL Expression",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
expressions: {
|
||||
// NOTE: this is a question, so it won't have semantic_type set as url, we'll need a model for that
|
||||
url_column: [
|
||||
"concat",
|
||||
"https://example.org/",
|
||||
["field", ORDERS.ID, { "base-type": "type/BigInteger" }],
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
["field", ORDERS.ID, { "base-type": "type/BigInteger" }],
|
||||
["expression", "url_column", { "base-type": "type/Text" }],
|
||||
["field", ORDERS.PRODUCT_ID, null],
|
||||
],
|
||||
limit: 5,
|
||||
},
|
||||
}).then(({ body: question }) => {
|
||||
cy.wrap(question.id).as("questionId");
|
||||
});
|
||||
|
||||
H.createDashboardWithQuestions({
|
||||
dashboardName: "Orders in a dashboard",
|
||||
questions: [
|
||||
@@ -60,7 +85,7 @@ describe("scenarios > embedding-sdk > dashboard-click-behavior", () => {
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
size_x: 12,
|
||||
size_x: 24,
|
||||
col: 0,
|
||||
visualization_settings: {
|
||||
column_settings: {
|
||||
@@ -77,7 +102,7 @@ describe("scenarios > embedding-sdk > dashboard-click-behavior", () => {
|
||||
},
|
||||
{
|
||||
size_x: 12,
|
||||
col: 12,
|
||||
col: 0,
|
||||
visualization_settings: {
|
||||
click_behavior: {
|
||||
type: "link",
|
||||
@@ -193,4 +218,123 @@ describe("scenarios > embedding-sdk > dashboard-click-behavior", () => {
|
||||
cy.findByTestId("visualization-root").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("columns that return a string url should be rendered as a link", () => {
|
||||
cy.get<string>("@questionId").then((questionId) => {
|
||||
mountSdkContent(<InteractiveQuestion questionId={questionId} />);
|
||||
});
|
||||
|
||||
cy.intercept("GET", "/api/card/*").as("getCard");
|
||||
cy.wait("@getCard");
|
||||
|
||||
cy.findByRole("link", { name: "https://example.org/448" })
|
||||
.should("have.length", 1)
|
||||
.should("have.attr", "href", "https://example.org/448");
|
||||
});
|
||||
|
||||
it("columns that have 'type/URL' semantic type should be rendered as a link", () => {
|
||||
cy.signIn("admin");
|
||||
|
||||
cy.get<string>("@questionId").then((questionId) => {
|
||||
// Convert question to model
|
||||
cy.request("GET", `/api/card/${questionId}`)
|
||||
.then((response) => {
|
||||
const questionData = response.body;
|
||||
return cy.request("PUT", `/api/card/${questionId}`, {
|
||||
...questionData,
|
||||
type: "model",
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
// re-fetch it as model
|
||||
return cy.request("GET", `/api/card/${questionId}`);
|
||||
})
|
||||
.then((modelResponse) => {
|
||||
// Update model column semantic type for url_column to type/URL
|
||||
|
||||
const modelData = modelResponse.body;
|
||||
const updatedMetadata = modelData.result_metadata.map((col: any) => {
|
||||
if (col.name === "url_column") {
|
||||
return { ...col, semantic_type: "type/URL" };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
return cy.request("PUT", `/api/card/${questionId}`, {
|
||||
...modelData,
|
||||
result_metadata: updatedMetadata,
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
// Create dashboard with the model
|
||||
H.createDashboardWithQuestions({
|
||||
dashboardName: "URL Dashboard",
|
||||
questions: [
|
||||
{
|
||||
name: "Orders with URLs",
|
||||
query: {
|
||||
"source-table": `card__${questionId}`,
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).then(({ dashboard }) => {
|
||||
mountSdkContent(
|
||||
<InteractiveDashboard dashboardId={dashboard.id} />,
|
||||
);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
H.getDashboardCard(0)
|
||||
.findByRole("link", {
|
||||
name: "https://example.org/448",
|
||||
})
|
||||
.should("have.attr", "href", "https://example.org/448");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("columns that have 'Display as Link' should be rendered as a link and use the custom rendering", () => {
|
||||
cy.signIn("admin");
|
||||
cy.intercept("GET", "/api/card/*").as("getCard");
|
||||
|
||||
cy.get<string>("@questionId").then((questionId) => {
|
||||
H.createDashboardWithQuestions({
|
||||
dashboardName: "URL Dashboard",
|
||||
questions: [
|
||||
{
|
||||
name: "Orders with URLs",
|
||||
query: {
|
||||
"source-table": `card__${questionId}`,
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
visualization_settings: {
|
||||
column_settings: {
|
||||
[JSON.stringify(["name", "ID"])]: {
|
||||
view_as: "link",
|
||||
link_text: "Link to {{ID}}",
|
||||
link_url: "https://example.org/{{ID}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).then(({ dashboard }) => {
|
||||
mountSdkContent(<EditableDashboard dashboardId={dashboard.id} />);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
H.getDashboardCard(0)
|
||||
.findByRole("link", {
|
||||
name: "Link to 448",
|
||||
})
|
||||
.should("have.attr", "href", "https://example.org/448");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
const { H } = cy;
|
||||
import { InteractiveQuestion } from "@metabase/embedding-sdk-react";
|
||||
|
||||
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
|
||||
import { mountSdkContent } from "e2e/support/helpers/embedding-sdk-component-testing/component-embedding-sdk-helpers";
|
||||
import { signInAsAdminAndEnableEmbeddingSdk } from "e2e/support/helpers/embedding-sdk-testing";
|
||||
import { mockAuthProviderAndJwtSignIn } from "e2e/support/helpers/embedding-sdk-testing/embedding-sdk-helpers";
|
||||
|
||||
describe("scenarios > embedding-sdk > interactive-question", () => {
|
||||
beforeEach(() => {
|
||||
signInAsAdminAndEnableEmbeddingSdk();
|
||||
|
||||
H.createNativeQuestion(
|
||||
{
|
||||
name: "SQL Orders",
|
||||
native: { query: "select * from ORDERS limit 5" },
|
||||
},
|
||||
{
|
||||
wrapId: true,
|
||||
idAlias: "sqlQuestionId",
|
||||
},
|
||||
);
|
||||
cy.signOut();
|
||||
|
||||
mockAuthProviderAndJwtSignIn();
|
||||
});
|
||||
|
||||
it("should be able to drill SQL question (EMB-273)", () => {
|
||||
cy.get("@sqlQuestionId").then((sqlQuestionId) => {
|
||||
mountSdkContent(
|
||||
<InteractiveQuestion questionId={sqlQuestionId} withParameters />,
|
||||
);
|
||||
});
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("SQL Orders").should("be.visible");
|
||||
H.assertTableRowsCount(5);
|
||||
|
||||
// Drill down to "See these Orders"
|
||||
cy.get("[data-dataset-index=0] > [data-column-id='PRODUCT_ID']")
|
||||
.should("have.text", "14")
|
||||
.click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Filter by this value").should("be.visible");
|
||||
cy.button(">").click();
|
||||
});
|
||||
H.assertTableRowsCount(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -171,4 +171,101 @@ describe("scenarios > embedding-sdk > metabot-question", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show suggestion buttons when no messages exist", () => {
|
||||
setup(metabotResponse);
|
||||
mockSuggestedPrompts();
|
||||
|
||||
mountSdkContent(<MetabotQuestion />);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
// Verify suggestion buttons are visible
|
||||
cy.findAllByTestId("metabot-suggestion-button", { timeout: 10_000 })
|
||||
.should("have.length", 3)
|
||||
.should("be.visible");
|
||||
|
||||
// Verify suggestion texts are shown in order
|
||||
cy.findAllByTestId("metabot-suggestion-button")
|
||||
.eq(0)
|
||||
.should("contain.text", "Show me total sales by product");
|
||||
|
||||
cy.findAllByTestId("metabot-suggestion-button")
|
||||
.eq(1)
|
||||
.should("contain.text", "What are the top performing products?");
|
||||
|
||||
cy.findAllByTestId("metabot-suggestion-button")
|
||||
.eq(2)
|
||||
.should("contain.text", "How many orders were placed last month?");
|
||||
|
||||
// Clicking on a suggestion should remove the suggestion buttons and add the user's message
|
||||
cy.findByText("Show me total sales by product").click();
|
||||
cy.findAllByTestId("metabot-suggestion-button").should("not.exist");
|
||||
cy.findAllByTestId("metabot-chat-message").should("have.length", 2);
|
||||
|
||||
cy.findAllByTestId("metabot-chat-message")
|
||||
.first()
|
||||
.should("contain.text", "Show me total sales by product");
|
||||
});
|
||||
});
|
||||
|
||||
it("should switch to stacked layout on small viewport", () => {
|
||||
setup(metabotResponse);
|
||||
mountSdkContent(<MetabotQuestion />);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
// On desktop, use the sidebar layout
|
||||
cy.findByTestId("metabot-question-container").should(
|
||||
"have.attr",
|
||||
"data-layout",
|
||||
"sidebar",
|
||||
);
|
||||
|
||||
// On mobile, use the stacked layout
|
||||
cy.viewport(400, 600);
|
||||
cy.findByTestId("metabot-overflow-button").should("be.visible");
|
||||
cy.findByTestId("metabot-question-container").should(
|
||||
"have.attr",
|
||||
"data-layout",
|
||||
"stacked",
|
||||
);
|
||||
|
||||
// On desktop, use the sidebar layout
|
||||
cy.viewport(1200, 800);
|
||||
cy.findByTestId("metabot-overflow-button").should("not.be.visible");
|
||||
cy.findByTestId("metabot-question-container").should(
|
||||
"have.attr",
|
||||
"data-layout",
|
||||
"sidebar",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const mockSuggestedPrompts = () => {
|
||||
cy.intercept(
|
||||
"GET",
|
||||
"/api/ee/metabot-v3/metabot/2/prompt-suggestions?limit=3&sample=true",
|
||||
{
|
||||
statusCode: 200,
|
||||
body: {
|
||||
prompts: [
|
||||
{
|
||||
id: 1,
|
||||
prompt: "Show me total sales by product",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
prompt: "What are the top performing products?",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
prompt: "How many orders were placed last month?",
|
||||
},
|
||||
],
|
||||
limit: 3,
|
||||
offset: 0,
|
||||
total: 3,
|
||||
},
|
||||
},
|
||||
).as("suggestedPrompts");
|
||||
};
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { CollectionBrowser } from "@metabase/embedding-sdk-react";
|
||||
import {
|
||||
CollectionBrowser,
|
||||
InteractiveQuestion,
|
||||
MetabaseProvider,
|
||||
} from "@metabase/embedding-sdk-react";
|
||||
|
||||
import { mountSdkContent } from "e2e/support/helpers/embedding-sdk-component-testing/component-embedding-sdk-helpers";
|
||||
import { signInAsAdminAndEnableEmbeddingSdk } from "e2e/support/helpers/embedding-sdk-testing";
|
||||
import { USERS } from "e2e/support/cypress_data";
|
||||
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
|
||||
import {
|
||||
DEFAULT_SDK_AUTH_PROVIDER_CONFIG,
|
||||
mountSdkContent,
|
||||
} from "e2e/support/helpers/embedding-sdk-component-testing/component-embedding-sdk-helpers";
|
||||
import {
|
||||
getSignedJwtForUser,
|
||||
signInAsAdminAndEnableEmbeddingSdk,
|
||||
} from "e2e/support/helpers/embedding-sdk-testing";
|
||||
import { mockAuthProviderAndJwtSignIn } from "e2e/support/helpers/embedding-sdk-testing/embedding-sdk-helpers";
|
||||
|
||||
describe("scenarios > embedding-sdk > requests", () => {
|
||||
@@ -23,5 +36,39 @@ describe("scenarios > embedding-sdk > requests", () => {
|
||||
expect(maxAgeHeader).to.equal("60");
|
||||
});
|
||||
});
|
||||
|
||||
it("properly performs session token refresh request when multiple data requests are triggered at the same time", () => {
|
||||
const expiredInSeconds = 60;
|
||||
cy.clock(Date.now());
|
||||
|
||||
cy.then(() => getSignedJwtForUser({ expiredInSeconds })).then((jwt) => {
|
||||
mockAuthProviderAndJwtSignIn(USERS.admin, { jwt });
|
||||
});
|
||||
|
||||
cy.mount(
|
||||
<MetabaseProvider authConfig={DEFAULT_SDK_AUTH_PROVIDER_CONFIG}>
|
||||
<InteractiveQuestion questionId={ORDERS_QUESTION_ID} />
|
||||
</MetabaseProvider>,
|
||||
);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Orders").should("exist");
|
||||
|
||||
cy.findByText("Group").click();
|
||||
|
||||
cy.findByRole("dialog").should("be.visible");
|
||||
|
||||
cy.tick(1000 * (expiredInSeconds + 5));
|
||||
|
||||
cy.findByRole("dialog").contains(/^ID$/).click();
|
||||
cy.findByRole("dialog").findByTestId("badge-remove-button").click();
|
||||
|
||||
cy.wait("@jwtProvider");
|
||||
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
// We ensure that both `dataset` requests are made after the token refresh request
|
||||
cy.get("@dataset.all").should("have.length", 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
||||
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import {
|
||||
type NativeQuestionDetails,
|
||||
createNativeQuestion,
|
||||
tableInteractiveBody,
|
||||
} from "e2e/support/helpers";
|
||||
@@ -15,122 +16,281 @@ import { signInAsAdminAndEnableEmbeddingSdk } from "e2e/support/helpers/embeddin
|
||||
import { mockAuthProviderAndJwtSignIn } from "e2e/support/helpers/embedding-sdk-testing/embedding-sdk-helpers";
|
||||
import { Box, Button } from "metabase/ui";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
import { createMockParameter } from "metabase-types/api/mocks";
|
||||
|
||||
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
describe("scenarios > embedding-sdk > interactive-question > native", () => {
|
||||
beforeEach(() => {
|
||||
signInAsAdminAndEnableEmbeddingSdk();
|
||||
const setup = ({ question }: { question: NativeQuestionDetails }) => {
|
||||
signInAsAdminAndEnableEmbeddingSdk();
|
||||
|
||||
createNativeQuestion(
|
||||
{
|
||||
native: {
|
||||
query: "SELECT * FROM orders WHERE {{ID}}",
|
||||
"template-tags": {
|
||||
ID: {
|
||||
id: "6b8b10ef-0104-1047-1e1b-2492d5954322",
|
||||
name: "ID",
|
||||
"display-name": "ID",
|
||||
type: "dimension",
|
||||
dimension: ["field", ORDERS.ID, null],
|
||||
"widget-type": "category",
|
||||
default: null,
|
||||
createNativeQuestion(question, { wrapId: true });
|
||||
|
||||
cy.signOut();
|
||||
mockAuthProviderAndJwtSignIn();
|
||||
};
|
||||
|
||||
const ensureParameterColumnValue = ({
|
||||
columnName,
|
||||
columnValue,
|
||||
}: {
|
||||
columnName: string;
|
||||
columnValue: string;
|
||||
}) => {
|
||||
tableInteractiveBody().within(() => {
|
||||
cy.get(`[data-column-id="${columnName}"]`).each((cell) => {
|
||||
cy.wrap(cell).should("have.text", columnValue);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe("scenarios > embedding-sdk > interactive-question > native", () => {
|
||||
describe("common", () => {
|
||||
beforeEach(() => {
|
||||
setup({
|
||||
question: {
|
||||
native: {
|
||||
query: "SELECT * FROM orders WHERE {{ID}}",
|
||||
"template-tags": {
|
||||
ID: {
|
||||
id: "6b8b10ef-0104-1047-1e1b-2492d5954322",
|
||||
name: "ID",
|
||||
"display-name": "ID",
|
||||
type: "dimension",
|
||||
dimension: ["field", ORDERS.ID, null],
|
||||
"widget-type": "category",
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ wrapId: true },
|
||||
);
|
||||
|
||||
cy.signOut();
|
||||
mockAuthProviderAndJwtSignIn();
|
||||
});
|
||||
|
||||
it("supports passing sql parameters to native questions", () => {
|
||||
mountInteractiveQuestion({ initialSqlParameters: { ID: ORDERS_ID } });
|
||||
|
||||
cy.wait("@cardQuery").then(({ response }) => {
|
||||
const { body } = response ?? {};
|
||||
|
||||
const rows = tableInteractiveBody().findAllByRole("row");
|
||||
|
||||
// There should be one row in the table
|
||||
rows.should("have.length", 1);
|
||||
|
||||
const idColumnIndex = body.data.cols.findIndex(
|
||||
(column: DatasetColumn) => column.name === "ID",
|
||||
);
|
||||
|
||||
// The first row should have the same ID column value as the initial SQL parameters
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
rows
|
||||
.findAllByTestId("cell-data")
|
||||
.eq(idColumnIndex)
|
||||
.should("have.text", String(ORDERS_ID));
|
||||
});
|
||||
});
|
||||
|
||||
it("should not crash when switching from new question to native question (metabase#60160)", () => {
|
||||
const TestComponent = ({
|
||||
nativeQuestionId,
|
||||
}: {
|
||||
nativeQuestionId: string | number;
|
||||
}) => {
|
||||
const [questionId, setQuestionId] = useState<string | number>("new");
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<InteractiveQuestion questionId={questionId} />
|
||||
|
||||
<Button onClick={() => setQuestionId(nativeQuestionId)}>
|
||||
use native question
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
cy.get<string>("@questionId").then((nativeQuestionId) => {
|
||||
mountSdkContent(<TestComponent nativeQuestionId={nativeQuestionId} />);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Pick your starting data").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
cy.findByText("use native question").click();
|
||||
it("supports passing sql parameters to native questions", () => {
|
||||
mountInteractiveQuestion({ initialSqlParameters: { ID: ORDERS_ID } });
|
||||
|
||||
cy.wait("@cardQuery").then(({ response }) => {
|
||||
const { body } = response ?? {};
|
||||
|
||||
const rows = tableInteractiveBody().findAllByRole("row");
|
||||
|
||||
// There should be one row in the table
|
||||
rows.should("have.length", 1);
|
||||
|
||||
const idColumnIndex = body.data.cols.findIndex(
|
||||
(column: DatasetColumn) => column.name === "ID",
|
||||
);
|
||||
|
||||
// The first row should have the same ID column value as the initial SQL parameters
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
rows
|
||||
.findAllByTestId("cell-data")
|
||||
.eq(idColumnIndex)
|
||||
.should("have.text", String(ORDERS_ID));
|
||||
});
|
||||
});
|
||||
|
||||
it("should not crash when switching from new question to native question (metabase#60160)", () => {
|
||||
const TestComponent = ({
|
||||
nativeQuestionId,
|
||||
}: {
|
||||
nativeQuestionId: string | number;
|
||||
}) => {
|
||||
const [questionId, setQuestionId] = useState<string | number>("new");
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<InteractiveQuestion questionId={questionId} />
|
||||
|
||||
<Button onClick={() => setQuestionId(nativeQuestionId)}>
|
||||
use native question
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
cy.get<string>("@questionId").then((nativeQuestionId) => {
|
||||
mountSdkContent(<TestComponent nativeQuestionId={nativeQuestionId} />);
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Pick your starting data").should("be.visible");
|
||||
});
|
||||
|
||||
cy.findByText("use native question").click();
|
||||
|
||||
cy.log("should show native question table data");
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Pick your starting data").should("not.exist");
|
||||
cy.findByText("test question").should("be.visible");
|
||||
cy.findByText("110.93").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("should not crash due to preview-query error");
|
||||
cy.on("uncaught:exception", (error) => {
|
||||
expect(
|
||||
error.message.includes(
|
||||
"preview-query cannot be called on native queries",
|
||||
),
|
||||
).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show filter/summarize/breakout buttons for native questions", () => {
|
||||
mountInteractiveQuestion({});
|
||||
|
||||
cy.log("should show native question table data");
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByText("Pick your starting data").should("not.exist");
|
||||
cy.log("should show native question data");
|
||||
cy.findByText("test question").should("be.visible");
|
||||
cy.findByText("110.93").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("should not crash due to preview-query error");
|
||||
cy.on("uncaught:exception", (error) => {
|
||||
expect(
|
||||
error.message.includes(
|
||||
"preview-query cannot be called on native queries",
|
||||
),
|
||||
).to.be.false;
|
||||
cy.log("should not show Filter button");
|
||||
cy.findByText("Filter").should("not.exist");
|
||||
|
||||
cy.log("should not show Summarize button");
|
||||
cy.findByText("Summarize").should("not.exist");
|
||||
|
||||
cy.log("should not show Group button");
|
||||
cy.findByText("Group").should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show filter/summarize/breakout buttons for native questions", () => {
|
||||
mountInteractiveQuestion({});
|
||||
describe("editable parameters for a native question", () => {
|
||||
beforeEach(() => {
|
||||
setup({
|
||||
question: {
|
||||
native: {
|
||||
query:
|
||||
"SELECT * FROM people WHERE state = {{State}} [[ and city = {{City}} ]] [[ and source = {{Source}} ]]",
|
||||
"template-tags": {
|
||||
State: {
|
||||
type: "text",
|
||||
name: "State",
|
||||
id: "1",
|
||||
"display-name": "State",
|
||||
},
|
||||
City: {
|
||||
type: "text",
|
||||
name: "City",
|
||||
id: "2",
|
||||
"display-name": "City",
|
||||
},
|
||||
Source: {
|
||||
type: "text",
|
||||
name: "Source",
|
||||
id: "3",
|
||||
"display-name": "Source",
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: [
|
||||
createMockParameter({
|
||||
id: "1",
|
||||
slug: "State",
|
||||
}),
|
||||
createMockParameter({
|
||||
id: "2",
|
||||
slug: "City",
|
||||
}),
|
||||
createMockParameter({
|
||||
id: "3",
|
||||
slug: "Source",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.log("should show native question data");
|
||||
cy.findByText("test question").should("be.visible");
|
||||
it("does not show editable sql parameters controls for the default view", () => {
|
||||
mountInteractiveQuestion({
|
||||
initialSqlParameters: { State: "NY" },
|
||||
hiddenParameters: ["Source"],
|
||||
});
|
||||
|
||||
cy.log("should not show Filter button");
|
||||
cy.findByText("Filter").should("not.exist");
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
cy.log("should not show Summarize button");
|
||||
cy.findByText("Summarize").should("not.exist");
|
||||
cy.findByTestId("parameter-widget").should("not.exist");
|
||||
cy.findByPlaceholderText("State").should("not.exist");
|
||||
cy.findByPlaceholderText("City").should("not.exist");
|
||||
cy.findByPlaceholderText("Source").should("not.exist");
|
||||
});
|
||||
|
||||
cy.log("should not show Group button");
|
||||
cy.findByText("Group").should("not.exist");
|
||||
describe("when `SqlParametersList` component is defined in a custom question layout", () => {
|
||||
it("allows to edit sql parameters", () => {
|
||||
mountInteractiveQuestion({
|
||||
hiddenParameters: ["Source"],
|
||||
children: (
|
||||
<>
|
||||
<InteractiveQuestion.Title />
|
||||
<InteractiveQuestion.SqlParametersList />
|
||||
<InteractiveQuestion.QuestionVisualization />
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
cy.findByPlaceholderText("State").should("exist");
|
||||
cy.findByPlaceholderText("City").should("exist");
|
||||
cy.findByPlaceholderText("Source").should("not.exist");
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByPlaceholderText("State").clear().type("NY{enter}");
|
||||
});
|
||||
|
||||
ensureParameterColumnValue({
|
||||
columnName: "STATE",
|
||||
columnValue: "NY",
|
||||
});
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByPlaceholderText("State").clear().type("AR{enter}");
|
||||
});
|
||||
|
||||
ensureParameterColumnValue({
|
||||
columnName: "STATE",
|
||||
columnValue: "AR",
|
||||
});
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByPlaceholderText("City").type("El Paso{enter}");
|
||||
});
|
||||
|
||||
ensureParameterColumnValue({
|
||||
columnName: "CITY",
|
||||
columnValue: "El Paso",
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes sql parameters controls with initial values and keeps an initial value when changing other parameters", () => {
|
||||
mountInteractiveQuestion({
|
||||
initialSqlParameters: { State: "AR" },
|
||||
hiddenParameters: ["Source"],
|
||||
children: (
|
||||
<>
|
||||
<InteractiveQuestion.Title />
|
||||
<InteractiveQuestion.SqlParametersList />
|
||||
<InteractiveQuestion.QuestionVisualization />
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
ensureParameterColumnValue({
|
||||
columnName: "STATE",
|
||||
columnValue: "AR",
|
||||
});
|
||||
|
||||
getSdkRoot().within(() => {
|
||||
cy.findByPlaceholderText("City").type("El Paso{enter}");
|
||||
});
|
||||
|
||||
ensureParameterColumnValue({
|
||||
columnName: "CITY",
|
||||
columnValue: "El Paso",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,14 +99,6 @@ describe("collection permissions", () => {
|
||||
it("should be able to duplicate the dashboard without obstructions from the modal (metabase#15256)", () => {
|
||||
duplicate("Orders in a dashboard");
|
||||
});
|
||||
|
||||
it(
|
||||
"should be able to duplicate the question (metabase#15255)",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
duplicate("Orders");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("archive", () => {
|
||||
|
||||
@@ -33,9 +33,6 @@ function createDashboardDetails({ parameters }) {
|
||||
|
||||
const TOAST_TIMEOUT = 16000;
|
||||
|
||||
const TOAST_MESSAGE =
|
||||
"You can make this dashboard snappier by turning off auto-applying filters.";
|
||||
|
||||
const filterToggleLabel = "Auto-apply filters";
|
||||
|
||||
describe(
|
||||
@@ -288,158 +285,6 @@ describe(
|
||||
H.assertTableRowsCount(200);
|
||||
});
|
||||
});
|
||||
|
||||
it(
|
||||
"should display a toast when a dashboard takes longer than 15s to load even without parameter values (but has parameters with default values)",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
cy.clock();
|
||||
openSlowDashboard();
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().within(() => {
|
||||
cy.findByText(TOAST_MESSAGE).should("be.visible");
|
||||
cy.button("Turn off").click();
|
||||
cy.wait("@updateDashboard");
|
||||
});
|
||||
|
||||
H.openDashboardSettingsSidebar();
|
||||
H.sidesheet()
|
||||
.findByLabelText(filterToggleLabel)
|
||||
.should("not.be.checked");
|
||||
// Gadget
|
||||
const filterDefaultValue = FILTER_WITH_DEFAULT_VALUE.default[0];
|
||||
H.filterWidget().findByText(filterDefaultValue).should("be.visible");
|
||||
H.getDashboardCard().within(() => {
|
||||
H.assertTableRowsCount(53);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should not display the toast when we clear out parameter default value",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
cy.clock();
|
||||
openSlowDashboard({ [FILTER_WITH_DEFAULT_VALUE.slug]: null });
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().should("not.exist");
|
||||
H.getDashboardCard().within(() => {
|
||||
H.assertTableRowsCount(200);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("auto-apply filter toast", () => {
|
||||
it(
|
||||
"should display a toast when a dashboard takes longer than 15s to load",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
cy.clock();
|
||||
createDashboard();
|
||||
openSlowDashboard({ [FILTER.slug]: "Gadget" });
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().within(() => {
|
||||
cy.findByText(TOAST_MESSAGE).should("be.visible");
|
||||
cy.button("Turn off").click();
|
||||
cy.wait("@updateDashboard");
|
||||
});
|
||||
|
||||
H.openDashboardSettingsSidebar();
|
||||
H.sidesheet()
|
||||
.findByLabelText(filterToggleLabel)
|
||||
.should("not.be.checked");
|
||||
H.closeDashboardSettingsSidebar();
|
||||
H.filterWidget().findByText("Gadget").should("be.visible");
|
||||
H.getDashboardCard().within(() => {
|
||||
H.assertTableRowsCount(53);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should display the toast indefinitely unless dismissing manually",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
cy.clock();
|
||||
createDashboard();
|
||||
openSlowDashboard({ [FILTER.slug]: "Gadget" });
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().findByText(TOAST_MESSAGE).should("be.visible");
|
||||
|
||||
cy.tick(100 * TOAST_TIMEOUT);
|
||||
H.undoToast().findByText(TOAST_MESSAGE).should("be.visible");
|
||||
|
||||
H.undoToast().icon("close").click();
|
||||
H.undoToast().should("not.exist");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should not display the toast when auto applying filters is disabled",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
cy.clock();
|
||||
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
|
||||
openSlowDashboard({ [FILTER.slug]: "Gadget" });
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().should("not.exist");
|
||||
H.filterWidget().findByText("Gadget").should("be.visible");
|
||||
H.getDashboardCard().within(() => {
|
||||
H.assertTableRowsCount(53);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should not display the toast if there are no parameter values",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
cy.clock();
|
||||
createDashboard();
|
||||
openSlowDashboard();
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().should("not.exist");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should not display the same toast twice for a dashboard",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
cy.clock();
|
||||
createDashboard();
|
||||
openSlowDashboard({ [FILTER.slug]: "Gadget" });
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().within(() => {
|
||||
cy.button("Turn off").should("be.visible");
|
||||
cy.icon("close").click();
|
||||
});
|
||||
H.filterWidget().findByText("Gadget").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Widget").click();
|
||||
cy.findByText("Update filter").click();
|
||||
});
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().should("not.exist");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("no collection curate permission", () => {
|
||||
@@ -456,19 +301,6 @@ describe(
|
||||
H.dashboardHeader().icon("ellipsis").click();
|
||||
H.popover().findByText("Edit settings").should("not.exist");
|
||||
});
|
||||
|
||||
it(
|
||||
"should not display a toast even when a dashboard takes longer than 15s to load",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
cy.clock();
|
||||
openSlowDashboard({ [FILTER.slug]: "Gadget" });
|
||||
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().should("not.exist");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("embeddings", () => {
|
||||
@@ -602,71 +434,6 @@ describe(
|
||||
H.assertTableRowsCount(54);
|
||||
});
|
||||
});
|
||||
|
||||
it(
|
||||
"should display a toast when a dashboard takes longer than 15s to load",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
createDashboard();
|
||||
// Not sure why I need to pass a date in this case, but it doesn't work without it.
|
||||
cy.clock(Date.now());
|
||||
openSlowFullAppEmbeddingDashboard({ [FILTER.slug]: "Gadget" });
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().within(() => {
|
||||
cy.findByText(TOAST_MESSAGE).should("be.visible");
|
||||
cy.button("Turn off").click();
|
||||
cy.wait("@updateDashboard");
|
||||
});
|
||||
|
||||
// In embedding we'll load bookmark after the dashboard is loaded, it's the opposite in normal app because bookmark is cached from somewhere else.
|
||||
// And somehow, dashboard card query will be completed before the dashboard even start to load, and in entity loader it uses `setTimeout`,
|
||||
// so to make sure callback in `setTimeout` is called, we need to advance the clock using cy.tick().
|
||||
cy.tick();
|
||||
|
||||
H.openDashboardSettingsSidebar();
|
||||
H.sidesheet()
|
||||
.findByLabelText(filterToggleLabel)
|
||||
.should("not.be.checked");
|
||||
H.filterWidget().findByText("Gadget").should("be.visible");
|
||||
|
||||
H.getDashboardCard().within(() => {
|
||||
H.assertTableRowsCount(53);
|
||||
});
|
||||
|
||||
// Card result should be updated after manually updating the filter
|
||||
H.filterWidget().icon("close").click();
|
||||
H.applyFilterButton().should("be.visible").click();
|
||||
|
||||
H.getDashboardCard().within(() => {
|
||||
H.assertTableRowsCount(200);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should not display a toast when a dashboard takes longer than 15s to load if users have no write access to a dashboard",
|
||||
{ tags: "@skip" },
|
||||
() => {
|
||||
createDashboard();
|
||||
cy.signIn("readonly");
|
||||
// Not sure why I need to pass a date in this case, but it doesn't work without it.
|
||||
cy.clock(Date.now());
|
||||
openSlowFullAppEmbeddingDashboard({ [FILTER.slug]: "Gadget" });
|
||||
cy.tick(TOAST_TIMEOUT);
|
||||
cy.wait("@cardQuery");
|
||||
H.undoToast().should("not.exist");
|
||||
|
||||
// In embedding we'll load bookmark after the dashboard is loaded, it's the opposite in normal app because bookmark is cached from somewhere else.
|
||||
// And somehow, dashboard card query will be completed before the dashboard even start to load, and in entity loader it uses `setTimeout`,
|
||||
// so to make sure callback in `setTimeout` is called, we need to advance the clock using cy.tick().
|
||||
cy.tick();
|
||||
|
||||
H.getDashboardCard().within(() => {
|
||||
H.assertTableRowsCount(53);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -753,21 +520,6 @@ const openDashboard = (params = {}) => {
|
||||
H.visitDashboard("@dashboardId", { params });
|
||||
};
|
||||
|
||||
const openSlowDashboard = (params = {}) => {
|
||||
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
|
||||
"cardQuery",
|
||||
);
|
||||
|
||||
cy.get("@dashboardId").then((dashboardId) => {
|
||||
return cy.visit({
|
||||
url: `/dashboard/${dashboardId}`,
|
||||
qs: params,
|
||||
});
|
||||
});
|
||||
|
||||
H.getDashboardCard().should("be.visible");
|
||||
};
|
||||
|
||||
const openSlowPublicDashboard = (params = {}) => {
|
||||
cy.intercept("GET", "/api/public/dashboard/*/dashcard/*/card/*").as(
|
||||
"cardQuery",
|
||||
@@ -798,21 +550,6 @@ const openSlowEmbeddingDashboard = (params = {}) => {
|
||||
H.getDashboardCard().should("be.visible");
|
||||
};
|
||||
|
||||
const openSlowFullAppEmbeddingDashboard = (params = {}) => {
|
||||
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
|
||||
"cardQuery",
|
||||
);
|
||||
|
||||
cy.get("@dashboardId").then((dashboardId) => {
|
||||
visitFullAppEmbeddingUrl({
|
||||
url: `/dashboard/${dashboardId}`,
|
||||
qs: params,
|
||||
});
|
||||
});
|
||||
|
||||
H.getDashboardCard().should("be.visible");
|
||||
};
|
||||
|
||||
const visitFullAppEmbeddingUrl = ({ url, qs }) => {
|
||||
cy.visit({
|
||||
url,
|
||||
|
||||
@@ -2204,3 +2204,60 @@ describe("issue 63176", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 64138", () => {
|
||||
const MAP_QUESTION = {
|
||||
query: {
|
||||
"source-table": PEOPLE_ID,
|
||||
},
|
||||
|
||||
display: "map",
|
||||
displayIsLocked: true,
|
||||
visualization_settings: {
|
||||
"map.type": "pin",
|
||||
"map.pin_type": "markers",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
|
||||
H.createQuestionAndDashboard({
|
||||
questionDetails: MAP_QUESTION,
|
||||
}).then(({ body: { dashboard_id } }) => {
|
||||
H.visitDashboard(dashboard_id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide map controls when editing dashboard (metabase#64138)", () => {
|
||||
H.editDashboard();
|
||||
|
||||
cy.log("hovering the map should not show the zoom controls");
|
||||
H.getDashboardCard(0)
|
||||
.realHover({
|
||||
position: "center",
|
||||
})
|
||||
.within(() => {
|
||||
cy.findByLabelText("Zoom in").should("not.exist");
|
||||
cy.findByText("Set as default view").should("be.visible").click();
|
||||
});
|
||||
|
||||
cy.log("hovering marker icons should not open their tooltips");
|
||||
getMarkerIcon(0).realHover();
|
||||
H.popover({ skipVisibilityCheck: true }).should("not.exist");
|
||||
|
||||
cy.log("clicking marker icons should not navigate to the question");
|
||||
getMarkerIcon(0).click({ force: true });
|
||||
cy.location("pathname").should("match", /^\/dashboard\/[0-9]+$/);
|
||||
H.modal().should("not.exist");
|
||||
});
|
||||
|
||||
function getMarkerIcon(index) {
|
||||
// pick the last one so it will be on top
|
||||
return H.getDashboardCard(index)
|
||||
.get(".leaflet-marker-icon")
|
||||
.should("have.length.gt", 0)
|
||||
.last();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -177,10 +177,16 @@ describe("scenarios > x-rays", { tags: "@slow" }, () => {
|
||||
|
||||
cy.button("Save this").click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Your dashboard was saved");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("See it").click();
|
||||
cy.log(
|
||||
"'See it' link should be displayed both in the header and in the toast",
|
||||
);
|
||||
H.undoToast()
|
||||
.should("contain", "Your dashboard was saved")
|
||||
.and("contain", "See it");
|
||||
|
||||
cy.findByTestId("automatic-dashboard-header").within(() => {
|
||||
cy.findByRole("link", { name: "See it" }).should("be.visible").click();
|
||||
});
|
||||
|
||||
cy.url().should("contain", "a-look-at-orders");
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ describe("scenarios - embedding hub", () => {
|
||||
.should("exist");
|
||||
});
|
||||
|
||||
it('"Generate a dashboard" card should work correctly', () => {
|
||||
it('"Create a dashboard" card should work correctly', () => {
|
||||
cy.visit("/admin/embedding/setup-guide");
|
||||
|
||||
cy.log("Find and click on 'Generate a dashboard' card");
|
||||
cy.log("Find and click on 'Create a dashboard' card");
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.findByText("Generate a dashboard")
|
||||
.findByText("Create a dashboard")
|
||||
.click();
|
||||
|
||||
cy.log("Select a table to generate dashboard from");
|
||||
@@ -44,11 +44,13 @@ describe("scenarios - embedding hub", () => {
|
||||
cy.url().should("include", "/auto/dashboard/table/");
|
||||
});
|
||||
|
||||
it('"Add data" card should work correctly', () => {
|
||||
it('"Connect a database" card should work correctly', () => {
|
||||
cy.visit("/admin/embedding/setup-guide");
|
||||
|
||||
cy.log("Find and click on 'Add data' card");
|
||||
cy.findByTestId("admin-layout-content").findByText("Add data").click();
|
||||
cy.log("Find and click on 'Connect a database' card");
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.findByText("Connect a database")
|
||||
.click();
|
||||
|
||||
cy.log("Add data modal should open");
|
||||
cy.findByRole("dialog").within(() => {
|
||||
@@ -70,14 +72,16 @@ describe("scenarios - embedding hub", () => {
|
||||
|
||||
cy.visit("/admin/embedding/setup-guide");
|
||||
|
||||
cy.log("'Add data' should not be marked as done");
|
||||
cy.log("'Connect a database' should not be marked as done");
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.findByText("Add data")
|
||||
.findByText("Connect a database")
|
||||
.closest("button")
|
||||
.findByText("Done")
|
||||
.should("not.exist");
|
||||
|
||||
cy.findByTestId("admin-layout-content").findByText("Add data").click();
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.findByText("Connect a database")
|
||||
.click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText("CSV").click();
|
||||
@@ -101,9 +105,9 @@ describe("scenarios - embedding hub", () => {
|
||||
|
||||
cy.wait("@getChecklist");
|
||||
|
||||
cy.log("'Add data' should be marked as done");
|
||||
cy.log("'Connect a database' should be marked as done");
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.findByText("Add data")
|
||||
.findByText("Connect a database")
|
||||
.closest("button")
|
||||
.scrollIntoView()
|
||||
.findByText("Done")
|
||||
@@ -143,11 +147,11 @@ describe("scenarios - embedding hub", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('"Embed in your code" card should take you to the embed flow', () => {
|
||||
it('"Get embed snippet" card should take you to the embed flow', () => {
|
||||
cy.visit("/admin/embedding/setup-guide");
|
||||
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.findByText("Embed in your code")
|
||||
.findByText("Get embed snippet")
|
||||
.click();
|
||||
|
||||
cy.url().should("include", "/embed-js?auth_method=user_session");
|
||||
@@ -189,8 +193,8 @@ describe("scenarios - embedding hub", () => {
|
||||
|
||||
cy.get("main").within(() => {
|
||||
cy.findByText("Create models").should("be.visible");
|
||||
cy.findByText("Generate a dashboard").should("be.visible");
|
||||
cy.findByText("Add data").should("be.visible").click();
|
||||
cy.findByText("Create a dashboard").should("be.visible");
|
||||
cy.findByText("Connect a database").should("be.visible").click();
|
||||
});
|
||||
|
||||
cy.log("Sanity check: add data modal should open");
|
||||
|
||||
@@ -104,14 +104,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.findByLabelText("Product ID")
|
||||
.should("contain", "456");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "initialParameters",
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
cy.log("both default values should be in the code snippet");
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Get Code").click();
|
||||
@@ -147,14 +139,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.findByTestId("dashboard-parameters-widget-container")
|
||||
.should("not.exist");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "hiddenParameters",
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
cy.log("code snippet should contain the hidden parameters");
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Get Code").click();
|
||||
@@ -218,11 +202,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
cy.findAllByText("75.41").first().should("be.visible");
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "initialSqlParameters",
|
||||
});
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Get Code").click();
|
||||
codeBlock().should("contain", "initial-sql-parameters=");
|
||||
|
||||
@@ -115,6 +115,32 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByLabelText("Existing Metabase session").should("be.checked");
|
||||
codeBlock().should("contain", '"useExistingUserSession": true');
|
||||
|
||||
cy.findByText(/Copy Code/).click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_code_copied",
|
||||
event_detail: "user_session",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should track embed_wizard_code_copied when copy event triggers", () => {
|
||||
navigateToGetCodeStep({
|
||||
experience: "dashboard",
|
||||
resourceName: DASHBOARD_NAME,
|
||||
});
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByLabelText("Existing Metabase session").should("be.checked");
|
||||
|
||||
codeBlock().should("contain", '"useExistingUserSession": true');
|
||||
codeBlock().trigger("copy");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_code_copied",
|
||||
event_detail: "user_session",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,13 +153,14 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByLabelText("Single sign-on (SSO)").click();
|
||||
codeBlock().should("not.contain", "useExistingUserSession");
|
||||
|
||||
cy.findByText(/Copy Code/).click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_auth_selected",
|
||||
event: "embed_wizard_code_copied",
|
||||
event_detail: "sso",
|
||||
});
|
||||
|
||||
codeBlock().should("not.contain", "useExistingUserSession");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,25 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
H.expectNoBadSnowplowEvents();
|
||||
});
|
||||
|
||||
it("can select a recent dashboard to embed", () => {
|
||||
it("tracks 'default' when keeping the default dashboard selection", () => {
|
||||
visitNewEmbedPage();
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Next").click();
|
||||
cy.findByText("Select a dashboard to embed").should("be.visible");
|
||||
|
||||
cy.log("first dashboard should be selected by default");
|
||||
getRecentItemCards().first().should("have.attr", "data-selected", "true");
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_resource_selection_completed",
|
||||
event_detail: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks 'custom' when selecting a different dashboard", () => {
|
||||
cy.log("add two dashboards to activity log");
|
||||
|
||||
H.createDashboard({ name: SECOND_DASHBOARD_NAME }).then(
|
||||
@@ -70,14 +88,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
cy.log("second dashboard can be selected");
|
||||
cy.findByText(SECOND_DASHBOARD_NAME).click();
|
||||
|
||||
cy.get("@secondDashboardId").then((secondDashboardId) => {
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_resource_selected",
|
||||
target_id: secondDashboardId,
|
||||
event_detail: "dashboard",
|
||||
});
|
||||
});
|
||||
|
||||
getRecentItemCards().eq(1).should("have.attr", "data-selected", "true");
|
||||
});
|
||||
|
||||
@@ -87,22 +97,11 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
cy.findByText(SECOND_DASHBOARD_NAME).should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("select the same the dashboard again");
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText(SECOND_DASHBOARD_NAME).click();
|
||||
cy.findByText(SECOND_DASHBOARD_NAME).click();
|
||||
});
|
||||
getEmbedSidebar().findByText("Next").click();
|
||||
|
||||
cy.get("@secondDashboardId").then((secondDashboardId) => {
|
||||
cy.log("only a single snowplow event should be sent");
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: "embed_wizard_resource_selected",
|
||||
target_id: secondDashboardId,
|
||||
event_detail: "dashboard",
|
||||
},
|
||||
1,
|
||||
);
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_resource_selection_completed",
|
||||
event_detail: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,13 +130,14 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
cy.log("second question can be selected");
|
||||
cy.findByText(SECOND_QUESTION_NAME).click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_resource_selected",
|
||||
target_id: ORDERS_BY_YEAR_QUESTION_ID,
|
||||
event_detail: "chart",
|
||||
});
|
||||
|
||||
getRecentItemCards().eq(1).should("have.attr", "data-selected", "true");
|
||||
|
||||
cy.findByText("Next").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_resource_selection_completed",
|
||||
event_detail: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
cy.log("selected question should be shown in the preview");
|
||||
@@ -166,14 +166,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
cy.findByText(SECOND_DASHBOARD_NAME).click();
|
||||
});
|
||||
|
||||
cy.get("@secondDashboardId").then((secondDashboardId) => {
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_resource_selected",
|
||||
target_id: secondDashboardId,
|
||||
event_detail: "dashboard",
|
||||
});
|
||||
});
|
||||
|
||||
cy.log("dashboard is added to the top of recents list and selected");
|
||||
getEmbedSidebar().within(() => {
|
||||
getRecentItemCards()
|
||||
@@ -204,12 +196,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
cy.findByText(FIRST_QUESTION_NAME).click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_resource_selected",
|
||||
target_id: ORDERS_COUNT_QUESTION_ID,
|
||||
event_detail: "chart",
|
||||
});
|
||||
|
||||
cy.log("question is added to the top of recents list and selected");
|
||||
getEmbedSidebar().within(() => {
|
||||
getRecentItemCards()
|
||||
@@ -217,6 +203,13 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.first()
|
||||
.should("contain", FIRST_QUESTION_NAME)
|
||||
.should("have.attr", "data-selected", "true");
|
||||
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_resource_selection_completed",
|
||||
event_detail: "custom",
|
||||
});
|
||||
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
@@ -39,8 +39,18 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
visitNewEmbedPage();
|
||||
assertRecentItemName("dashboard", dashboardName);
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
|
||||
H.waitForSimpleEmbedIframesToLoad();
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_experience_completed",
|
||||
event_detail: "default",
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.log("dashboard title is visible");
|
||||
cy.findByText(dashboardName).should("be.visible");
|
||||
@@ -59,12 +69,16 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
|
||||
visitNewEmbedPage();
|
||||
assertRecentItemName("card", questionName);
|
||||
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
|
||||
|
||||
getEmbedSidebar().findByText("Chart").click();
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Chart").click();
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_experience_selected",
|
||||
event_detail: "chart",
|
||||
event: "embed_wizard_experience_completed",
|
||||
event_detail: "custom=chart",
|
||||
});
|
||||
|
||||
cy.wait("@cardQuery");
|
||||
@@ -77,11 +91,15 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
|
||||
it("shows exploration template when selected", { tags: "@skip" }, () => {
|
||||
visitNewEmbedPage();
|
||||
getEmbedSidebar().findByText("Exploration").click();
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Exploration").click();
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_experience_selected",
|
||||
event_detail: "exploration",
|
||||
event: "embed_wizard_experience_completed",
|
||||
event_detail: "custom=exploration",
|
||||
});
|
||||
|
||||
H.waitForSimpleEmbedIframesToLoad();
|
||||
@@ -94,11 +112,15 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
|
||||
it("shows browser template when selected", () => {
|
||||
visitNewEmbedPage();
|
||||
getEmbedSidebar().findByText("Browser").click();
|
||||
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Browser").click();
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_experience_selected",
|
||||
event_detail: "browser",
|
||||
event: "embed_wizard_experience_completed",
|
||||
event_detail: "custom=browser",
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
@@ -144,11 +166,14 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
visitNewEmbedPage();
|
||||
cy.wait("@emptyRecentItems");
|
||||
|
||||
getEmbedSidebar().findByText("Chart").click();
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Chart").click();
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_experience_selected",
|
||||
event_detail: "chart",
|
||||
event: "embed_wizard_experience_completed",
|
||||
event_detail: "custom=chart",
|
||||
});
|
||||
|
||||
H.waitForSimpleEmbedIframesToLoad();
|
||||
|
||||
@@ -55,11 +55,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.click()
|
||||
.should("not.be.checked");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "drills",
|
||||
});
|
||||
|
||||
cy.log("drill-through should be disabled in the preview");
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("110.93").click();
|
||||
@@ -68,6 +63,13 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
|
||||
cy.log("snippet should be updated");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail:
|
||||
"settings=custom,theme=default,auth=user_session,drills=false,withDownloads=false,withTitle=true",
|
||||
});
|
||||
|
||||
codeBlock().should("contain", 'drills="false"');
|
||||
});
|
||||
|
||||
@@ -91,17 +93,19 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.click()
|
||||
.should("be.checked");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "withDownloads",
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("export-as-pdf-button")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("snippet should be updated");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail:
|
||||
"settings=custom,theme=default,auth=user_session,drills=true,withDownloads=true,withTitle=true",
|
||||
});
|
||||
|
||||
codeBlock().should("contain", 'with-downloads="true"');
|
||||
});
|
||||
|
||||
@@ -125,17 +129,19 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.click()
|
||||
.should("not.be.checked");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "withTitle",
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Orders in a dashboard")
|
||||
.should("not.exist");
|
||||
|
||||
cy.log("snippet should be updated");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail:
|
||||
"settings=custom,theme=default,auth=user_session,drills=true,withDownloads=false,withTitle=false",
|
||||
});
|
||||
|
||||
codeBlock().should("contain", 'with-title="false"');
|
||||
});
|
||||
|
||||
@@ -161,11 +167,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.click()
|
||||
.should("not.be.checked");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "drills",
|
||||
});
|
||||
|
||||
cy.log("drill-through should be disabled in chart preview");
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("18,760").click();
|
||||
@@ -200,17 +201,19 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.click()
|
||||
.should("be.checked");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "withDownloads",
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByTestId("question-download-widget-button")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("snippet should be updated");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail:
|
||||
"settings=custom,theme=default,auth=user_session,drills=true,withDownloads=true,withTitle=true,isSaveEnabled=false",
|
||||
});
|
||||
|
||||
codeBlock().should("contain", 'with-downloads="true"');
|
||||
});
|
||||
|
||||
@@ -233,11 +236,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.click()
|
||||
.should("not.be.checked");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "withTitle",
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Orders, Count")
|
||||
.should("not.exist");
|
||||
@@ -277,22 +275,15 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Orders, Count")
|
||||
.should("be.visible");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "withTitle",
|
||||
},
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
["exploration", "chart"].forEach((experience) => {
|
||||
it(`toggles save button for ${experience}`, () => {
|
||||
navigateToEmbedOptionsStep({
|
||||
experience,
|
||||
...(experience === "chart" && { resourceName: QUESTION_NAME }),
|
||||
});
|
||||
navigateToEmbedOptionsStep(
|
||||
experience === "chart"
|
||||
? { experience: "chart", resourceName: QUESTION_NAME }
|
||||
: { experience: "exploration" },
|
||||
);
|
||||
|
||||
if (experience === "exploration") {
|
||||
cy.log("visualize a question to enable the save button");
|
||||
@@ -315,11 +306,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.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(() => {
|
||||
@@ -334,6 +320,15 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
|
||||
cy.log("snippet should be updated");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail:
|
||||
experience === "chart"
|
||||
? "settings=custom,theme=default,auth=user_session,drills=true,withDownloads=false,withTitle=true,isSaveEnabled=true"
|
||||
: "settings=custom,theme=default,auth=user_session,isSaveEnabled=true",
|
||||
});
|
||||
|
||||
codeBlock().should("contain", 'is-save-enabled="true"');
|
||||
});
|
||||
});
|
||||
@@ -358,17 +353,19 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.click()
|
||||
.should("be.checked");
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "readOnly",
|
||||
});
|
||||
|
||||
H.getSimpleEmbedIframeContent().within(() => {
|
||||
cy.findByText("New dashboard").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("snippet should be updated");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail:
|
||||
"settings=custom,theme=default,auth=user_session,readOnly=false",
|
||||
});
|
||||
|
||||
codeBlock().should("contain", 'read-only="false"');
|
||||
});
|
||||
|
||||
@@ -397,11 +394,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
.type("rgb(255, 0, 0)");
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "theme",
|
||||
});
|
||||
|
||||
cy.log("table header cell should now be red");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findAllByTestId("cell-data")
|
||||
@@ -414,6 +406,12 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
cy.log("snippet should be updated");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail:
|
||||
"settings=custom,theme=custom,auth=user_session,drills=true,withDownloads=false,withTitle=true",
|
||||
});
|
||||
|
||||
codeBlock().should("contain", '"theme": {');
|
||||
codeBlock().should("contain", '"colors": {');
|
||||
codeBlock().should("contain", '"brand": "#FF0000"');
|
||||
@@ -424,11 +422,6 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
cy.log("click reset button");
|
||||
getEmbedSidebar().findByLabelText("Reset colors").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_option_changed",
|
||||
event_detail: "theme",
|
||||
});
|
||||
|
||||
cy.log("table header should be back to default blue");
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findAllByTestId("cell-data")
|
||||
@@ -441,6 +434,11 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
cy.log("snippet should not contain theme colors");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
codeBlock().should("not.contain", '"theme": {');
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail: "settings=default",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives colors for dark theme palette", () => {
|
||||
@@ -475,6 +473,12 @@ H.describeWithSnowplow(suiteTitle, () => {
|
||||
cy.log("check that derived colors are applied to snippet");
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail:
|
||||
"settings=custom,theme=custom,auth=user_session,drills=true,withDownloads=false,withTitle=true",
|
||||
});
|
||||
|
||||
// derived-colors-for-embed-flow.unit.spec.ts contains the tests for other derived colors.
|
||||
cy.log("dark mode colors should be derived");
|
||||
codeBlock().should("contain", '"background-hover": "rgb(27, 27, 27)"');
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("scenarios > embedding > sdk iframe embedding > authentication", () =>
|
||||
(win as any).metabaseConfig = {
|
||||
...(win as any).metabaseConfig,
|
||||
fetchRequestToken: async () => {
|
||||
const jwt = await getSignedJwtForUser(USERS.admin);
|
||||
const jwt = await getSignedJwtForUser({ user: USERS.admin });
|
||||
|
||||
return { jwt };
|
||||
},
|
||||
|
||||
@@ -225,14 +225,14 @@ describe("scenarios > metrics > collection", () => {
|
||||
H.createQuestion({
|
||||
...ORDERS_SCALAR_MODEL_METRIC,
|
||||
collection_id: FIRST_COLLECTION_ID,
|
||||
}).then(({ body: card }) => {
|
||||
}).then(() => {
|
||||
cy.signIn("nocollection");
|
||||
H.visitCollection(FIRST_COLLECTION_ID);
|
||||
});
|
||||
H.getPinnedSection()
|
||||
.findByTestId("scalar-container")
|
||||
.findByText("18,760")
|
||||
.should("be.visible");
|
||||
.findByTestId("scalar-value")
|
||||
.should("not.be.empty")
|
||||
.and("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -818,3 +818,38 @@ describe("issue 59356", () => {
|
||||
cy.get("@dataset.all").should("have.length", 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 63711", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
});
|
||||
|
||||
it("Completions should be visible when there are a lot of options (metabase#63711)", () => {
|
||||
H.startNewNativeQuestion();
|
||||
H.NativeEditor.type("s");
|
||||
|
||||
cy.log("completions should be scrollable");
|
||||
H.NativeEditor.completions()
|
||||
.findByLabelText("Completions")
|
||||
.then(($el) => {
|
||||
const element = $el[0];
|
||||
cy.wrap(element.scrollHeight).should("be.gt", element.clientHeight);
|
||||
});
|
||||
|
||||
cy.log("completions should not cut off the height of the inner element");
|
||||
H.NativeEditor.completion("SAVEPOINT")
|
||||
.should("be.visible")
|
||||
.then(($outerElement) => {
|
||||
cy.wrap($outerElement)
|
||||
.findByText("AVEPOINT")
|
||||
.should("be.visible")
|
||||
.then(($innerElement) => {
|
||||
cy.wrap($innerElement[0].offsetHeight).should(
|
||||
"be.eq",
|
||||
$outerElement[0].clientHeight,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -314,6 +314,19 @@ describe("scenarios > setup", () => {
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
// There are only one step in the setup flow, so there is no need to show step numbers.
|
||||
it("should not show step numbers in cloud embedding use case", () => {
|
||||
H.mockSessionProperty("is-hosted?", true);
|
||||
H.mockSessionProperty("token-features", { hosting: true });
|
||||
|
||||
cy.visit(
|
||||
"/setup?first_name=John&last_name=Doe&email=john@doe.test&site_name=Doe%20Unlimited&use_case=embedding",
|
||||
);
|
||||
|
||||
H.main().findByText("What should we call you?").should("be.visible");
|
||||
cy.findByTestId("step-number").should("not.exist");
|
||||
});
|
||||
|
||||
it("should allow localization in the 'embedding' setup flow", () => {
|
||||
cy.visit(
|
||||
"/setup?first_name=John&last_name=Doe&email=john@doe.test&site_name=Doe%20Unlimited&use_case=embedding",
|
||||
|
||||
@@ -436,6 +436,8 @@ describe("issue 28106", () => {
|
||||
.findByTestId("scroll-container")
|
||||
.as("schemasList");
|
||||
|
||||
H.entityPickerModalLevel(2).should("contain", "Animals");
|
||||
|
||||
cy.get("@schemasList").scrollTo("bottom");
|
||||
|
||||
// assert scrolling worked and the last item is visible
|
||||
|
||||
@@ -7,14 +7,22 @@ import { getEmbedSidebar } from "../embedding/sdk-iframe-embedding-setup/helpers
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("embed flow pre-selection from sharing modal", () => {
|
||||
const suiteTitle = "scenarios > sharing > embed flow pre-selection";
|
||||
|
||||
H.describeWithSnowplow(suiteTitle, () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
H.resetSnowplow();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("pro-self-hosted");
|
||||
H.activateToken("bleeding-edge");
|
||||
H.enableTracking();
|
||||
H.updateSetting("enable-embedding-simple", true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
H.expectNoBadSnowplowEvents();
|
||||
});
|
||||
|
||||
it("pre-selects dashboard in embed flow when opened from dashboard sharing modal", () => {
|
||||
H.visitDashboard(ORDERS_DASHBOARD_ID);
|
||||
H.openSharingMenu("Embed");
|
||||
@@ -23,6 +31,8 @@ describe("embed flow pre-selection from sharing modal", () => {
|
||||
cy.findByRole("link", { name: "Embedded Analytics JS" }).click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
|
||||
|
||||
cy.location("search").should((search) => {
|
||||
const params = new URLSearchParams(search);
|
||||
|
||||
@@ -30,7 +40,6 @@ describe("embed flow pre-selection from sharing modal", () => {
|
||||
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");
|
||||
@@ -41,15 +50,24 @@ describe("embed flow pre-selection from sharing modal", () => {
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Orders in a dashboard", { timeout: 10_000 })
|
||||
.should("be.visible");
|
||||
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail: "settings=default",
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
H.getEmbedModalSharingPane()
|
||||
.findByRole("link", { name: "Embedded Analytics JS" })
|
||||
.click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
|
||||
|
||||
cy.location("search").should((search) => {
|
||||
const params = new URLSearchParams(search);
|
||||
@@ -58,7 +76,6 @@ describe("embed flow pre-selection from sharing modal", () => {
|
||||
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");
|
||||
@@ -69,5 +86,58 @@ describe("embed flow pre-selection from sharing modal", () => {
|
||||
H.getSimpleEmbedIframeContent()
|
||||
.findByText("Orders", { timeout: 10_000 })
|
||||
.should("be.visible");
|
||||
|
||||
getEmbedSidebar().findByText("Get Code").click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_options_completed",
|
||||
event_detail: "settings=default",
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks default resources for pre-selected dashboard", () => {
|
||||
H.visitDashboard(ORDERS_DASHBOARD_ID);
|
||||
H.openSharingMenu("Embed");
|
||||
|
||||
H.getEmbedModalSharingPane().within(() => {
|
||||
cy.findByRole("link", { name: "Embedded Analytics JS" }).click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
|
||||
|
||||
cy.log("go back to resource selection step");
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Back").click();
|
||||
cy.findByText("Select a dashboard to embed").should("be.visible");
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_resource_selection_completed",
|
||||
event_detail: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks default resources for pre-selected question", () => {
|
||||
H.visitQuestion(ORDERS_QUESTION_ID);
|
||||
H.openSharingMenu("Embed");
|
||||
|
||||
H.getEmbedModalSharingPane().within(() => {
|
||||
cy.findByRole("link", { name: "Embedded Analytics JS" }).click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({ event: "embed_wizard_opened" });
|
||||
|
||||
cy.log("go back to resource selection step");
|
||||
getEmbedSidebar().within(() => {
|
||||
cy.findByText("Back").click();
|
||||
cy.findByText("Select a chart to embed").should("be.visible");
|
||||
cy.findByText("Next").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "embed_wizard_resource_selection_completed",
|
||||
event_detail: "default",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { resetSnowplow } from "e2e/support/helpers/e2e-snowplow-helpers";
|
||||
|
||||
@@ -403,35 +403,51 @@ describe("scenarios > table-editing", () => {
|
||||
beforeEach(() => {
|
||||
resetSnowplow();
|
||||
|
||||
cy.intercept("GET", `/api/table/${ORDERS_ID}/query_metadata`).as(
|
||||
"getOrdersTable",
|
||||
);
|
||||
H.restore("postgres-writable");
|
||||
H.resetTestTable({ type: "postgres", table: "scoreboard_actions" });
|
||||
H.resyncDatabase({
|
||||
dbId: WRITABLE_DB_ID,
|
||||
tableName: "scoreboard_actions",
|
||||
});
|
||||
|
||||
H.activateToken("bleeding-edge");
|
||||
setTableEditingEnabledForDB(WRITABLE_DB_ID);
|
||||
|
||||
cy.intercept("GET", "/api/table/*/query_metadata").as("getTableMetadata");
|
||||
cy.intercept("POST", "api/dataset").as("getTableDataQuery");
|
||||
cy.intercept("POST", "api/ee/action-v2/execute-bulk").as("executeBulk");
|
||||
|
||||
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
|
||||
H.getTableId({ name: "scoreboard_actions" }).then((tableId) => {
|
||||
cy.visit(`/browse/databases/${WRITABLE_DB_ID}/tables/${tableId}/edit`);
|
||||
});
|
||||
|
||||
cy.wait("@getOrdersTable");
|
||||
cy.wait("@getTableMetadata");
|
||||
});
|
||||
|
||||
it("should allow to create a row", () => {
|
||||
cy.findByTestId("new-record-button").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Create a new record").should("be.visible");
|
||||
cy.findByTestId("Tax-field-input").type("50");
|
||||
cy.findByTestId("Total-field-input").type("100");
|
||||
cy.findByTestId("Discount-field-input").type("10");
|
||||
cy.findByTestId("create-row-form-submit-button").click();
|
||||
});
|
||||
H.modal().findByText("Create a new record").should("be.visible");
|
||||
|
||||
cy.findByTestId("Team Name-field-input").click();
|
||||
H.popover().findByRole("textbox").type("New York Bricks");
|
||||
H.popover()
|
||||
.findByText(/Add option/)
|
||||
.click();
|
||||
cy.findByTestId("Score-field-input").type("987");
|
||||
cy.findByTestId("Status-field-input").click();
|
||||
H.popover().findByText("active").click();
|
||||
|
||||
cy.findByTestId("create-row-form-submit-button").click();
|
||||
|
||||
cy.wait("@executeBulk").then(({ response, request }) => {
|
||||
expect(request.body.action).to.equal("data-grid.row/create");
|
||||
expect(response?.body.outputs[0].op).to.equal("created");
|
||||
expect(response?.body.outputs[0].row.TAX).to.equal(50);
|
||||
expect(response?.body.outputs[0].row.TOTAL).to.equal(100);
|
||||
expect(response?.body.outputs[0].row.DISCOUNT).to.equal(10);
|
||||
expect(response?.body.outputs[0].row.score).to.equal(987);
|
||||
expect(response?.body.outputs[0].row.status).to.equal("active");
|
||||
expect(response?.body.outputs[0].row.team_name).to.equal(
|
||||
"New York Bricks",
|
||||
);
|
||||
});
|
||||
|
||||
H.undoToast().within(() => {
|
||||
@@ -439,18 +455,25 @@ describe("scenarios > table-editing", () => {
|
||||
cy.findByLabelText("close icon").click();
|
||||
});
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "edit_data_record_modified",
|
||||
event_detail: "create",
|
||||
target_id: ORDERS_ID,
|
||||
triggered_from: "modal",
|
||||
result: "success",
|
||||
cy.findByTestId("table-root").within(() => {
|
||||
cy.findByText("New York Bricks").should("be.visible");
|
||||
cy.findByText("987").should("be.visible");
|
||||
});
|
||||
|
||||
H.getTableId({ name: "scoreboard_actions" }).then((tableId) => {
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "edit_data_record_modified",
|
||||
event_detail: "create",
|
||||
target_id: tableId,
|
||||
triggered_from: "modal",
|
||||
result: "success",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to delete multiple rows (bulk)", () => {
|
||||
cy.findAllByTestId("row-select-checkbox").eq(15).click();
|
||||
cy.findAllByTestId("row-select-checkbox").eq(16).click();
|
||||
cy.findAllByTestId("row-select-checkbox").eq(1).click();
|
||||
cy.findAllByTestId("row-select-checkbox").eq(2).click();
|
||||
|
||||
cy.log("should not show edit icon when rows are selected");
|
||||
cy.findByTestId("row-edit-icon").should("not.exist");
|
||||
|
||||
@@ -1282,3 +1282,42 @@ describe("issue 54271", () => {
|
||||
H.assertQueryBuilderRowCount(1076);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 63671", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
H.createQuestion(
|
||||
{
|
||||
query: {
|
||||
"source-table": PRODUCTS_ID,
|
||||
aggregation: [["count"]],
|
||||
breakout: [
|
||||
[
|
||||
"field",
|
||||
PRODUCTS.CREATED_AT,
|
||||
{
|
||||
"temporal-unit": "year",
|
||||
},
|
||||
],
|
||||
],
|
||||
filter: [
|
||||
"between",
|
||||
["field", PRODUCTS.CREATED_AT, null],
|
||||
"2025-01-01",
|
||||
"2025-12-31",
|
||||
],
|
||||
},
|
||||
display: "bar",
|
||||
},
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show an extra value on bar charts when there is only value on the x axis (metabase#63671)", () => {
|
||||
cy.findByTestId("query-visualization-root")
|
||||
.findByText("2025")
|
||||
.should("have.length", 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -639,8 +639,7 @@ describe("issue 37726", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// unskip once metabase#42049 is addressed
|
||||
describe("issue 42049", { tags: "@skip" }, () => {
|
||||
describe("issue 42049", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
@@ -703,11 +702,11 @@ describe("issue 42049", { tags: "@skip" }, () => {
|
||||
cy.get("@headerCells").eq(1).should("have.text", "Created At");
|
||||
cy.get("@headerCells").eq(2).should("have.text", "Quantity");
|
||||
|
||||
cy.findByRole("button", { name: "Filter" }).click();
|
||||
cy.findByTestId("question-filter-header").click();
|
||||
|
||||
cy.findByRole("dialog").within(() => {
|
||||
cy.findByRole("button", { name: "Last month" }).click();
|
||||
cy.findByRole("button", { name: "Apply filters" }).click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Created At").click();
|
||||
cy.button("Previous month").click();
|
||||
});
|
||||
|
||||
cy.wait("@cardQuery");
|
||||
|
||||
@@ -94,8 +94,8 @@
|
||||
feedback :- :map]
|
||||
(let [token (premium-features/premium-embedding-token)
|
||||
base-url (store-api/store-api-url)]
|
||||
(when (or (str/blank? token) (str/blank? base-url))
|
||||
(throw (ex-info "Cannot build a request. The license token and/or Store api url are missing!" {})))
|
||||
(api/check-400 (not (or (str/blank? token) (str/blank? base-url)))
|
||||
"Cannot build a request. The license token and/or Store api url are missing!")
|
||||
(try
|
||||
(http/post (str base-url "/api/v2/metabot/feedback/" token)
|
||||
{:content-type :json
|
||||
|
||||
@@ -264,9 +264,10 @@
|
||||
(let [options (cond-> arguments
|
||||
(= (:with-field-values? arguments) false) (assoc :field-values-fn identity))
|
||||
details (cond
|
||||
(int? model-id) (u/prog1 (card-details model-id (assoc options :only-model true))
|
||||
(api/check (= :model (:type <>)) 404
|
||||
(format "ID %s is not a valid model id, it's a question" model-id)))
|
||||
(int? model-id) (let [card (card-details model-id (assoc options :only-model true))]
|
||||
(if (= :model (:type card))
|
||||
card
|
||||
(format "ID %s is not a valid model id, it's a question" model-id)))
|
||||
(int? table-id) (table-details table-id options)
|
||||
(string? table-id) (if-let [[_ card-id] (re-matches #"card__(\d+)" table-id)]
|
||||
(card-details (parse-long card-id) options)
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
use-verified-content? (if metabot-id
|
||||
(:use_verified_content metabot)
|
||||
false)
|
||||
limit (or limit 50)
|
||||
search-fn (fn [query]
|
||||
(let [search-context (search/search-context
|
||||
(merge
|
||||
@@ -122,7 +123,7 @@
|
||||
:current-user-perms @api/*current-user-permissions-set*
|
||||
:context :metabot
|
||||
:archived false
|
||||
:limit (or limit 50)
|
||||
:limit limit
|
||||
:offset 0}
|
||||
(when use-verified-content?
|
||||
{:verified true})))
|
||||
@@ -131,5 +132,5 @@
|
||||
;; Create futures for parallel execution
|
||||
futures (mapv #(future (search-fn %)) all-queries)
|
||||
result-lists (mapv deref futures)
|
||||
fused-results (reciprocal-rank-fusion result-lists)]
|
||||
fused-results (take limit (reciprocal-rank-fusion result-lists))]
|
||||
(map transform-search-result fused-results)))
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
(ns metabase-enterprise.sandbox.query-processor.middleware.column-level-perms-check
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase.api.common :refer [*current-user-id*]]
|
||||
;; legacy usage -- don't use Legacy MBQL utils in QP code going forward, prefer Lib. This will be updated to use
|
||||
;; Lib only soon
|
||||
^{:clj-kondo/ignore [:discouraged-namespace]}
|
||||
[metabase.legacy-mbql.schema :as mbql.s]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.schema :as lib.schema]
|
||||
[metabase.lib.util.match :as lib.util.match]
|
||||
[metabase.premium-features.core :refer [defenterprise-schema]]
|
||||
[metabase.query-processor.schema :as qp.schema]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(defn- field-ids [form]
|
||||
(set (lib.util.match/match form
|
||||
[:field (id :guard integer?) _]
|
||||
id)))
|
||||
|
||||
(def ^:private ^:dynamic *gtap-perms*
|
||||
"TODO: these used to be passed as a `:gtap-perms` key as part of QP context... not sure where this used to get passed
|
||||
in."
|
||||
nil)
|
||||
|
||||
(mu/defn- maybe-apply-column-level-perms-check*
|
||||
[{{{source-query-fields :fields} :source-query} :query, :as query} :- ::mbql.s/Query]
|
||||
(let [restricted-field-ids (and *gtap-perms*
|
||||
(field-ids source-query-fields))]
|
||||
(when (seq restricted-field-ids)
|
||||
(let [fields-ids-in-query (field-ids (m/dissoc-in query [:query :source-query]))]
|
||||
(when-not (every? restricted-field-ids fields-ids-in-query)
|
||||
(log/warnf "User '%s' attempted to access an inaccessible field. Accessible fields %s, fields in query %s"
|
||||
*current-user-id* (pr-str restricted-field-ids) (pr-str fields-ids-in-query))
|
||||
(throw (ex-info (str (tru "User not able to query field")) {:status 403})))))))
|
||||
|
||||
(defenterprise-schema maybe-apply-column-level-perms-check :- ::qp.schema/qp
|
||||
"Check column-level permissions if applicable."
|
||||
:feature :sandboxes
|
||||
[qp :- ::qp.schema/qp]
|
||||
(mu/fn [query :- ::lib.schema/query
|
||||
rff :- ::qp.schema/rff]
|
||||
;; TODO (Cam 9/11/25) -- update this middleware to use Lib/MBQL 5
|
||||
(maybe-apply-column-level-perms-check* (lib/->legacy-MBQL query))
|
||||
(qp query rff)))
|
||||
@@ -10,6 +10,7 @@
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.users.models.user :as user]
|
||||
[metabase.users.schema :as users.schema]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :as i18n]
|
||||
[metabase.util.malli :as mu]
|
||||
@@ -171,7 +172,7 @@
|
||||
:active (:is_active user)
|
||||
:meta {:resourceType "User"}})
|
||||
|
||||
(mu/defn ^:private scim-user->mb :- user/NewUser
|
||||
(mu/defn ^:private scim-user->mb :- users.schema/NewUser
|
||||
"Given a SCIM user, returns a Metabase user."
|
||||
[user]
|
||||
(let [{email :userName name-obj :name locale :locale is-active? :active} user
|
||||
|
||||
@@ -87,8 +87,8 @@
|
||||
(catch Exception e
|
||||
(reset! err e)
|
||||
(if full-stacktrace
|
||||
(log/error e "Error during serialization")
|
||||
(log/error (u/strip-error e "Error during serialization"))))))]
|
||||
(log/error e "Error during serialization export")
|
||||
(log/error (u/strip-error e "Error during serialization export"))))))]
|
||||
{:archive (when (.exists dst)
|
||||
dst)
|
||||
:log-file (when (.exists log-file)
|
||||
@@ -142,8 +142,8 @@
|
||||
(catch Exception e
|
||||
(reset! err e)
|
||||
(if full-stacktrace
|
||||
(log/error e "Error during serialization")
|
||||
(log/error (u/strip-error e "Error during serialization"))))))]
|
||||
(log/error e "Error during serialization import")
|
||||
(log/error (u/strip-error e "Error during serialization import"))))))]
|
||||
{:log-file log-file
|
||||
:status (:status (ex-data @err))
|
||||
:error-message (when @err
|
||||
|
||||
@@ -270,8 +270,8 @@
|
||||
(u/strip-error @err nil))})
|
||||
(when @err
|
||||
(if (:full-stacktrace opts)
|
||||
(log/error @err "Error during serialization")
|
||||
(log/error (u/strip-error @err "Error during deserialization")))
|
||||
(log/error @err "Error during serialization export")
|
||||
(log/error (u/strip-error @err "Error during serialization export")))
|
||||
(throw (ex-info (ex-message @err) {:cmd/exit true})))
|
||||
(log/info (format "Export to '%s' complete!" path) (u/emoji "🚛💨 📦"))
|
||||
report))
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
[metabase-enterprise.sso.settings :as ee.sso.settings]
|
||||
[metabase.premium-features.core :refer [defenterprise-schema]]
|
||||
[metabase.sso.core :as sso]
|
||||
[metabase.users.models.user :as user]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2])
|
||||
@@ -63,10 +62,10 @@
|
||||
{:keys [sync-groups?], :as settings} :- sso/LDAPSettings]
|
||||
(let [user (or (attribute-synced-user user-info)
|
||||
(sso-utils/check-user-provisioning :ldap)
|
||||
(-> (user/create-new-ldap-auth-user! {:first_name first-name
|
||||
:last_name last-name
|
||||
:email email
|
||||
:login_attributes attributes})
|
||||
(-> (sso/create-new-ldap-auth-user! {:first_name first-name
|
||||
:last_name last-name
|
||||
:email email
|
||||
:login_attributes attributes})
|
||||
(assoc :is_active true)))]
|
||||
(u/prog1 user
|
||||
(when sync-groups?
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [deferred-tru]]
|
||||
@@ -119,15 +120,17 @@
|
||||
(api/check (not (transforms.util/target-table-exists? body))
|
||||
403
|
||||
(deferred-tru "A table with that name already exists."))
|
||||
(t2/with-transaction [_]
|
||||
(let [tag-ids (:tag_ids body)
|
||||
transform (t2/insert-returning-instance!
|
||||
:model/Transform (select-keys body [:name :description :source :target :run_trigger]))]
|
||||
;; Add tag associations if provided
|
||||
(when (seq tag-ids)
|
||||
(transform.model/update-transform-tags! (:id transform) tag-ids))
|
||||
;; Return with hydrated tag_ids
|
||||
(t2/hydrate transform :transform_tag_ids))))
|
||||
(let [transform (t2/with-transaction [_]
|
||||
(let [tag-ids (:tag_ids body)
|
||||
transform (t2/insert-returning-instance!
|
||||
:model/Transform (select-keys body [:name :description :source :target :run_trigger]))]
|
||||
;; Add tag associations if provided
|
||||
(when (seq tag-ids)
|
||||
(transform.model/update-transform-tags! (:id transform) tag-ids))
|
||||
;; Return with hydrated tag_ids
|
||||
(t2/hydrate transform :transform_tag_ids)))]
|
||||
(events/publish-event! :event/transform-create {:object transform :user-id api/*current-user-id*})
|
||||
transform))
|
||||
|
||||
(api.macros/defendpoint :get "/:id"
|
||||
"Get a specific transform."
|
||||
@@ -188,28 +191,29 @@
|
||||
[:tag_ids {:optional true} [:sequential ms/PositiveInt]]]]
|
||||
(log/info "put transform" id)
|
||||
(api/check-superuser)
|
||||
(t2/with-transaction [_]
|
||||
;; Cycle detection should occur within the transaction to avoid race
|
||||
(let [old (t2/select-one :model/Transform id)
|
||||
new (merge old body)
|
||||
target-fields #(-> % :target (select-keys [:schema :name]))]
|
||||
;; we must validate on a full transform object
|
||||
(check-feature-enabled! new)
|
||||
(check-database-feature new)
|
||||
(when (transforms.util/query-transform? old)
|
||||
(when-let [{:keys [cycle-str]} (transforms.ordering/get-transform-cycle new)]
|
||||
(throw (ex-info (str "Cyclic transform definitions detected: " cycle-str)
|
||||
{:status-code 400}))))
|
||||
(api/check (not (and (not= (target-fields old) (target-fields new))
|
||||
(transforms.util/target-table-exists? new)))
|
||||
403
|
||||
(deferred-tru "A table with that name already exists.")))
|
||||
|
||||
(t2/update! :model/Transform id (dissoc body :tag_ids))
|
||||
;; Update tag associations if provided
|
||||
(when (contains? body :tag_ids)
|
||||
(transform.model/update-transform-tags! id (:tag_ids body)))
|
||||
(t2/hydrate (t2/select-one :model/Transform id) :transform_tag_ids)))
|
||||
(let [transform (t2/with-transaction [_]
|
||||
;; Cycle detection should occur within the transaction to avoid race
|
||||
(let [old (t2/select-one :model/Transform id)
|
||||
new (merge old body)
|
||||
target-fields #(-> % :target (select-keys [:schema :name]))]
|
||||
;; we must validate on a full transform object
|
||||
(check-feature-enabled! new)
|
||||
(check-database-feature new)
|
||||
(when (transforms.util/query-transform? old)
|
||||
(when-let [{:keys [cycle-str]} (transforms.ordering/get-transform-cycle new)]
|
||||
(throw (ex-info (str "Cyclic transform definitions detected: " cycle-str)
|
||||
{:status-code 400}))))
|
||||
(api/check (not (and (not= (target-fields old) (target-fields new))
|
||||
(transforms.util/target-table-exists? new)))
|
||||
403
|
||||
(deferred-tru "A table with that name already exists.")))
|
||||
(t2/update! :model/Transform id (dissoc body :tag_ids))
|
||||
;; Update tag associations if provided
|
||||
(when (contains? body :tag_ids)
|
||||
(transform.model/update-transform-tags! id (:tag_ids body)))
|
||||
(t2/hydrate (t2/select-one :model/Transform id) :transform_tag_ids))]
|
||||
(events/publish-event! :event/transform-update {:object transform :user-id api/*current-user-id*})
|
||||
transform))
|
||||
|
||||
(api.macros/defendpoint :delete "/:id"
|
||||
"Delete a transform."
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
(ns metabase-enterprise.transforms.core
|
||||
"API namespace for the `metabase-enterprise.transform` module."
|
||||
(:require
|
||||
[metabase-enterprise.transforms.models.transform-run]
|
||||
[metabase-enterprise.transforms.settings]
|
||||
[potemkin :as p]))
|
||||
|
||||
(p/import-vars
|
||||
[metabase-enterprise.transforms.settings
|
||||
transform-timeout])
|
||||
transform-timeout]
|
||||
[metabase-enterprise.transforms.models.transform-run
|
||||
timeout-run!])
|
||||
|
||||
@@ -14,16 +14,12 @@
|
||||
[run-id]
|
||||
(t2/query-one
|
||||
[(str "INSERT INTO transform_run_cancelation (run_id) "
|
||||
"SELECT ? "
|
||||
"WHERE "
|
||||
" EXISTS (SELECT 1 "
|
||||
" FROM transform_run "
|
||||
" WHERE (transform_run.id = ?) "
|
||||
" AND transform_run.is_active)"
|
||||
" AND NOT EXISTS (SELECT 1 "
|
||||
" FROM transform_run_cancelation "
|
||||
" WHERE run_id = ?)")
|
||||
run-id run-id run-id])
|
||||
"SELECT transform_run.id "
|
||||
"FROM transform_run "
|
||||
"WHERE transform_run.id = ? "
|
||||
"AND transform_run.is_active "
|
||||
"AND NOT EXISTS (SELECT 1 FROM transform_run_cancelation WHERE run_id = ?)")
|
||||
run-id run-id])
|
||||
(t2/update! :model/TransformRun
|
||||
:id run-id
|
||||
{:status "canceling"})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
[metabase-enterprise.transforms-python.python-runner :as python-runner]
|
||||
[metabase-enterprise.transforms-python.s3 :as s3]
|
||||
[metabase-enterprise.transforms-python.settings :as transforms-python.settings]
|
||||
[metabase-enterprise.transforms.core :as transforms]
|
||||
[metabase-enterprise.transforms.instrumentation :as transforms.instrumentation]
|
||||
[metabase-enterprise.transforms.util :as transforms.util]
|
||||
[metabase.driver :as driver]
|
||||
@@ -17,8 +18,10 @@
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.io Closeable File)
|
||||
(java.io Closeable)
|
||||
(java.net SocketException)
|
||||
(java.nio.file Files)
|
||||
(java.nio.file.attribute FileAttribute)
|
||||
(java.time Duration)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -240,13 +243,14 @@
|
||||
;; TODO restructure things such that s3 can we swapped out for other transfer mechanisms
|
||||
(with-open [log-future-ref (open-python-message-update-future! run-id message-log)
|
||||
shared-storage-ref (s3/open-shared-storage! (:source-tables source))]
|
||||
(let [driver (:engine db)
|
||||
server-url (transforms-python.settings/python-runner-url)
|
||||
_ (python-runner/copy-tables-to-s3! {:run-id run-id
|
||||
:shared-storage @shared-storage-ref
|
||||
:table-name->id (:source-tables source)
|
||||
:cancel-chan cancel-chan})
|
||||
_ (start-cancellation-process! server-url run-id cancel-chan) ; inherits lifetime of cancel-chan
|
||||
(let [driver (:engine db)
|
||||
server-url (transforms-python.settings/python-runner-url)
|
||||
_ (python-runner/copy-tables-to-s3! {:run-id run-id
|
||||
:shared-storage @shared-storage-ref
|
||||
:table-name->id (:source-tables source)
|
||||
:cancel-chan cancel-chan})
|
||||
_ (start-cancellation-process! server-url run-id cancel-chan) ; inherits lifetime of cancel-chan
|
||||
|
||||
{:keys [status body] :as response}
|
||||
(python-runner/execute-python-code-http-call!
|
||||
{:server-url server-url
|
||||
@@ -254,29 +258,33 @@
|
||||
:run-id run-id
|
||||
:table-name->id (:source-tables source)
|
||||
:shared-storage @shared-storage-ref})
|
||||
;; TODO temporary to keep more code stable while refactoring
|
||||
;; no need to materialize these early (i.e output we can stream directly into a tmp file or db if small)
|
||||
{:keys [output output-manifest events]} (python-runner/read-output-objects @shared-storage-ref)]
|
||||
|
||||
output-manifest (python-runner/read-output-manifest @shared-storage-ref)
|
||||
events (python-runner/read-events @shared-storage-ref)]
|
||||
(.close log-future-ref) ; early close to force any writes to flush
|
||||
(when (seq events)
|
||||
(replace-python-logs! message-log events))
|
||||
(if (not= 200 status)
|
||||
(throw (ex-info "Python runner call failed"
|
||||
{:transform-message (i18n/tru "Python execution failure (exit code {0})" (:exit_code body "?"))
|
||||
:status-code 400
|
||||
:api-status-code status
|
||||
:body body
|
||||
:events events}))
|
||||
(do
|
||||
(when (:timeout body)
|
||||
(transforms/timeout-run! run-id))
|
||||
(throw (ex-info "Python runner call failed"
|
||||
{:transform-message (i18n/tru "Python execution failure (exit code {0})" (:exit_code body "?"))
|
||||
:status-code 400
|
||||
:api-status-code status
|
||||
:body body
|
||||
:events events})))
|
||||
(try
|
||||
(let [temp-file (File/createTempFile "transform-output-" ".jsonl")]
|
||||
(let [temp-path (Files/createTempFile "transform-output-" ".jsonl" (u/varargs FileAttribute))
|
||||
temp-file (.toFile temp-path)]
|
||||
(when-not (seq (:fields output-manifest))
|
||||
(throw (ex-info "No fields in metadata"
|
||||
{:metadata output-manifest
|
||||
:raw-body body
|
||||
:events events})))
|
||||
(try
|
||||
(with-open [writer (io/writer temp-file)]
|
||||
(.write writer ^String output))
|
||||
(with-open [in (python-runner/open-output @shared-storage-ref)]
|
||||
(io/copy in temp-file))
|
||||
(let [file-size (.length temp-file)]
|
||||
(transforms.instrumentation/with-stage-timing [run-id :file-to-dwh]
|
||||
(transfer-file-to-db driver db transform output-manifest temp-file))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
(:require
|
||||
[clj-http.client :as http]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.transforms-python.s3 :as s3]
|
||||
[metabase-enterprise.transforms-python.settings :as transforms-python.settings]
|
||||
@@ -20,7 +19,7 @@
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(clojure.lang PersistentQueue)
|
||||
(java.io BufferedWriter File OutputStream OutputStreamWriter)
|
||||
(java.io BufferedWriter File InputStream OutputStream OutputStreamWriter)
|
||||
(java.nio.charset StandardCharsets)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -184,7 +183,7 @@
|
||||
|
||||
payload {:code code
|
||||
:library (t2/select-fn->fn :path :source :model/PythonLibrary)
|
||||
:timeout (* 30 60) ;; 30 mins harcoded for now
|
||||
:timeout (transforms-python.settings/python-runner-timeout-seconds)
|
||||
:request_id run-id
|
||||
:output_url (:url output)
|
||||
:output_manifest_url (:url output-manifest)
|
||||
@@ -204,17 +203,24 @@
|
||||
{:error string-if-error}))
|
||||
string-if-error)))))
|
||||
|
||||
;; temporary, we should not need to realize data/events files into memory longer term
|
||||
(defn read-output-objects
|
||||
"Temporary function that strings/jsons stuff in S3 and returns it for compatibility."
|
||||
(defn read-events
|
||||
"Returns a vector of event contents (or nil if the event file does not exist)"
|
||||
[{:keys [s3-client bucket-name objects]}]
|
||||
(let [{:keys [output output-manifest events]} objects
|
||||
output-content (s3/read-to-string s3-client bucket-name (:path output) nil)
|
||||
output-manifest-content (s3/read-to-string s3-client bucket-name (:path output-manifest) "{}")
|
||||
events-content (s3/read-to-string s3-client bucket-name (:path events))]
|
||||
{:output output-content
|
||||
:output-manifest (json/decode+kw output-manifest-content)
|
||||
:events (mapv json/decode+kw (str/split-lines events-content))}))
|
||||
;; note we expect :events to be a small file, limits ought to be enforced by the runner
|
||||
(when-some [in (s3/open-object s3-client bucket-name (:path (:events objects)))]
|
||||
(with-open [in in
|
||||
rdr (io/reader in)]
|
||||
(mapv json/decode+kw (line-seq rdr)))))
|
||||
|
||||
(defn read-output-manifest
|
||||
"Return the output manifest map. Returns nil if it does not exist."
|
||||
[{:keys [s3-client bucket-name objects]}]
|
||||
(json/decode+kw (s3/read-to-string s3-client bucket-name (:path (:output-manifest objects)) "{}")))
|
||||
|
||||
(defn open-output
|
||||
"Return an InputStream with the output jsonl contents. Close with .close. Returns nil if the object does not exist."
|
||||
^InputStream [{:keys [s3-client bucket-name objects]}]
|
||||
(s3/open-object s3-client bucket-name (:path (:output objects))))
|
||||
|
||||
(defn cancel-python-code-http-call!
|
||||
"Calls the /cancel endpoint of the python runner. Returns immediately."
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[metabase.util.log :as log])
|
||||
(:import
|
||||
(clojure.lang IDeref)
|
||||
(java.io Closeable File)
|
||||
(java.io Closeable File InputStream)
|
||||
(java.net URI)
|
||||
(java.time Duration)
|
||||
(software.amazon.awssdk.auth.credentials AwsBasicCredentials StaticCredentialsProvider)
|
||||
@@ -183,19 +183,22 @@
|
||||
(let [^PutObjectRequest request (put-object-request bucket-name key)]
|
||||
(.putObject s3-client request (RequestBody/fromFile file))))
|
||||
|
||||
;; TODO optimize our ingestion to stream the data. reading a string will not work in any case for binary files.
|
||||
(defn open-object
|
||||
"Get back the contents of the given key as a InputStream."
|
||||
^InputStream [^S3Client s3-client ^String bucket-name ^String key]
|
||||
(try
|
||||
(let [^GetObjectRequest request (get-object-request bucket-name key)]
|
||||
(.getObject s3-client request))
|
||||
(catch NoSuchKeyException _ nil)))
|
||||
|
||||
(defn read-to-string
|
||||
"Get back the contents of the given key as a string."
|
||||
([s3-client bucket-name key] (read-to-string s3-client bucket-name key ::throw))
|
||||
([s3-client bucket-name key] (read-to-string s3-client bucket-name key nil))
|
||||
([^S3Client s3-client ^String bucket-name ^String key not-found]
|
||||
(try
|
||||
(let [^GetObjectRequest request (get-object-request bucket-name key)
|
||||
response (.getObject s3-client request)]
|
||||
(slurp response))
|
||||
(catch NoSuchKeyException e
|
||||
(if (identical? ::throw not-found)
|
||||
(throw e)
|
||||
not-found)))))
|
||||
(if-some [in (open-object s3-client bucket-name key)]
|
||||
(with-open [ret in]
|
||||
(slurp ret))
|
||||
not-found)))
|
||||
|
||||
(defn delete
|
||||
;; TODO better error handling
|
||||
|
||||
@@ -113,3 +113,14 @@
|
||||
:export? false
|
||||
:encryption :no
|
||||
:audit :getter)
|
||||
|
||||
(setting/defsetting python-runner-timeout-seconds
|
||||
(deferred-tru "Timeout in seconds for Python script execution. Defaults to 30 minutes (1800 seconds).")
|
||||
:type :integer
|
||||
:visibility :admin
|
||||
:default 1800 ;; 30 minutes
|
||||
:feature :transforms-python
|
||||
:doc false
|
||||
:export? false
|
||||
:encryption :no
|
||||
:audit :getter)
|
||||
|
||||
@@ -572,11 +572,11 @@
|
||||
(mt/with-temp [:model/Card {card-1-id :id, :as card-1} {:collection_id (u/the-id collection)
|
||||
:dataset_query card-1-query}]
|
||||
(let [card-2-query (mt/native-query
|
||||
{:query "SELECT * FROM {{card}}"
|
||||
:template-tags {"card" {:name "card"
|
||||
:display-name "card"
|
||||
:type :card
|
||||
:card-id card-1-id}}})]
|
||||
{:query "SELECT * FROM {{card}}"
|
||||
:template-tags {"card" {:name "card"
|
||||
:display-name "card"
|
||||
:type :card
|
||||
:card-id card-1-id}}})]
|
||||
(mt/with-temp [:model/Card card-2 {:collection_id (u/the-id collection)
|
||||
:dataset_query card-2-query}]
|
||||
(testing "should be able to read nested-nested Card if we have Collection permissions"
|
||||
@@ -625,10 +625,10 @@
|
||||
(mt/with-temp [:model/Collection collection]
|
||||
(perms/grant-collection-read-permissions! (perms/all-users-group) collection)
|
||||
(let [card-1-query (mt/native-query
|
||||
{:query (str "SELECT id, name, category_id, latitude, longitude, price "
|
||||
"FROM venues "
|
||||
"ORDER BY id ASC "
|
||||
"LIMIT 2")})]
|
||||
{:query (str "SELECT id, name, category_id, latitude, longitude, price "
|
||||
"FROM venues "
|
||||
"ORDER BY id ASC "
|
||||
"LIMIT 2")})]
|
||||
(mt/with-temp [:model/Card {card-1-id :id, :as card-1} {:collection_id (u/the-id collection)
|
||||
:dataset_query card-1-query}]
|
||||
(let [card-2-query (mt/mbql-query nil
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.client-test :as client-test]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot.u]
|
||||
[metabase.premium-features.token-check :as token-check]
|
||||
[metabase.premium-features.token-check-test :as token-check-test]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util.json :as json]
|
||||
[toucan2.core :as t2]))
|
||||
@@ -63,29 +61,23 @@
|
||||
|
||||
(deftest feedback-endpoint-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(with-redefs [token-check/fetch-token-status (fn [_x]
|
||||
{:valid true
|
||||
:status "fake"
|
||||
:features ["test" "fixture"]
|
||||
:trial false})]
|
||||
(let [premium-token (token-check-test/random-token)
|
||||
store-url "http://hm.example"]
|
||||
(mt/with-random-premium-token! [premium-token]
|
||||
(let [store-url "http://hm.example"]
|
||||
(testing "Submits feedback to Harbormaster with token and base URL"
|
||||
(mt/with-temporary-setting-values [premium-embedding-token premium-token
|
||||
store-api-url store-url]
|
||||
(let [captured (atom nil)
|
||||
feedback {:metabot_id 1
|
||||
:feedback {:positive true
|
||||
:message_id "m-1"
|
||||
:freeform_feedback "ok"}
|
||||
:conversation_data {}
|
||||
:version "v0.0.0"
|
||||
:submission_time "2025-01-01T00:00:00Z"
|
||||
:is_admin false}
|
||||
(mt/with-temporary-setting-values [store-api-url store-url]
|
||||
(let [captured (atom nil)
|
||||
feedback {:metabot_id 1
|
||||
:feedback {:positive true
|
||||
:message_id "m-1"
|
||||
:freeform_feedback "ok"}
|
||||
:conversation_data {}
|
||||
:version "v0.0.0"
|
||||
:submission_time "2025-01-01T00:00:00Z"
|
||||
:is_admin false}
|
||||
expected-url (str store-url "/api/v2/metabot/feedback/" premium-token)]
|
||||
(mt/with-dynamic-fn-redefs
|
||||
[http/post (fn [url opts]
|
||||
(reset! captured {:url url
|
||||
(reset! captured {:url url
|
||||
:body (json/decode+kw (:body opts))}))]
|
||||
(let [_resp (mt/user-http-request :rasta :post 204 "ee/metabot-v3/feedback" feedback)]
|
||||
(is (= {:url expected-url :body feedback}
|
||||
@@ -95,11 +87,11 @@
|
||||
(mt/with-temporary-setting-values [premium-embedding-token premium-token]
|
||||
(mt/with-dynamic-fn-redefs
|
||||
[http/post (fn [_url _opts]
|
||||
(throw (ex-info "boom" {})))]
|
||||
(throw (ex-info "boom" {:status 404})))]
|
||||
(mt/user-http-request :rasta :post 500 "ee/metabot-v3/feedback" {:any "payload"}))))
|
||||
|
||||
;; We're not testing the branch where the store-api-url is missing because that defsetting
|
||||
;; has the default value. It doesn't work well with `with-temporary-setting-values` helper.
|
||||
(testing "Throws when premium token is missing"
|
||||
(mt/with-temporary-setting-values [premium-embedding-token nil]
|
||||
(mt/user-http-request :rasta :post 500 "ee/metabot-v3/feedback" {:foo "bar"})))))))
|
||||
(mt/user-http-request :rasta :post 400 "ee/metabot-v3/feedback" {:foo "bar"})))))))
|
||||
|
||||
@@ -32,11 +32,11 @@
|
||||
["native"
|
||||
{:gtaps {:venues {:query
|
||||
(mt/native-query
|
||||
{:query "SELECT id, name, category_id FROM venues WHERE category_id = {{cat}}"
|
||||
:template-tags {"cat" {:id "__MY_CAT__"
|
||||
:name "cat"
|
||||
:display-name "Cat id"
|
||||
:type :number}}})
|
||||
{:query "SELECT id, name, category_id FROM venues WHERE category_id = {{cat}}"
|
||||
:template-tags {"cat" {:id "__MY_CAT__"
|
||||
:name "cat"
|
||||
:display-name "Cat id"
|
||||
:type :number}}})
|
||||
:remappings {:cat [:variable [:template-tag "cat"]]}}}
|
||||
:attributes {:cat 50}}]]]
|
||||
(testing (format "GTAP rule is a %s query" query-type)
|
||||
|
||||
@@ -88,17 +88,17 @@
|
||||
(driver/with-driver (or driver/*driver* :h2)
|
||||
(assert (driver.u/supports? driver/*driver* :native-parameters (mt/db)))
|
||||
{:query (mt/native-query
|
||||
{:query
|
||||
(format-honeysql
|
||||
{:select [:*]
|
||||
:from [[(identifier :venues)]]
|
||||
:where [:=
|
||||
(identifier :venues :category_id)
|
||||
[:raw "{{cat}}"]]
|
||||
:order-by [[(identifier :venues :id) :asc]]})
|
||||
{:query
|
||||
(format-honeysql
|
||||
{:select [:*]
|
||||
:from [[(identifier :venues)]]
|
||||
:where [:=
|
||||
(identifier :venues :category_id)
|
||||
[:raw "{{cat}}"]]
|
||||
:order-by [[(identifier :venues :id) :asc]]})
|
||||
|
||||
:template_tags
|
||||
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
|
||||
:template_tags
|
||||
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
|
||||
:remappings {:cat ["variable" ["template-tag" "cat"]]}}))
|
||||
|
||||
(defn- parameterized-sql-with-join-sandbox-def
|
||||
@@ -107,38 +107,38 @@
|
||||
(driver/with-driver (or driver/*driver* :h2)
|
||||
(assert (driver.u/supports? driver/*driver* :native-parameters (mt/db)))
|
||||
{:query (mt/native-query
|
||||
{:query
|
||||
(format-honeysql
|
||||
{:select [[(identifier :checkins :id)]
|
||||
[(identifier :checkins :user_id)]
|
||||
{:query
|
||||
(format-honeysql
|
||||
{:select [[(identifier :checkins :id)]
|
||||
[(identifier :checkins :user_id)]
|
||||
;; these columns are ILLEGAL, you're NOT allowed to add columns not in the original table.
|
||||
;; We should complain loudly and then ignore them.
|
||||
[(identifier :venues :name)]
|
||||
[(identifier :venues :category_id)]]
|
||||
:from [[(identifier :checkins)]]
|
||||
:left-join [[(identifier :venues)]
|
||||
[:= (identifier :checkins :venue_id) (identifier :venues :id)]]
|
||||
:where [:=
|
||||
(identifier :checkins :user_id)
|
||||
[:raw "{{user}}"]]
|
||||
:order-by [[(identifier :checkins :id) :asc]]})
|
||||
[(identifier :venues :name)]
|
||||
[(identifier :venues :category_id)]]
|
||||
:from [[(identifier :checkins)]]
|
||||
:left-join [[(identifier :venues)]
|
||||
[:= (identifier :checkins :venue_id) (identifier :venues :id)]]
|
||||
:where [:=
|
||||
(identifier :checkins :user_id)
|
||||
[:raw "{{user}}"]]
|
||||
:order-by [[(identifier :checkins :id) :asc]]})
|
||||
|
||||
:template_tags
|
||||
{"user" {:name "user"
|
||||
:display-name "User ID"
|
||||
:type :number
|
||||
:required true}}})
|
||||
:template_tags
|
||||
{"user" {:name "user"
|
||||
:display-name "User ID"
|
||||
:type :number
|
||||
:required true}}})
|
||||
:remappings {:user ["variable" ["template-tag" "user"]]}}))
|
||||
|
||||
(defn- venue-names-native-sandbox-def []
|
||||
(driver/with-driver (or driver/*driver* :h2)
|
||||
{:query (mt/native-query
|
||||
{:query
|
||||
(format-honeysql
|
||||
{:select [[(identifier :venues :id)]
|
||||
[(identifier :venues :name)]]
|
||||
:from [[(identifier :venues)]]
|
||||
:order-by [[(identifier :venues :id) :asc]]})})}))
|
||||
{:query
|
||||
(format-honeysql
|
||||
{:select [[(identifier :venues :id)]
|
||||
[(identifier :venues :name)]]
|
||||
:from [[(identifier :venues)]]
|
||||
:order-by [[(identifier :venues :id) :asc]]})})}))
|
||||
|
||||
(defn- run-venues-count-query []
|
||||
(mt/format-rows-by
|
||||
@@ -599,13 +599,13 @@
|
||||
(fn [{:keys [cols expected-cols]}]
|
||||
(testing "A query with an equivalent native query sandbox should have the same metadata"
|
||||
(met/with-gtaps! {:gtaps {:venues {:query (mt/native-query
|
||||
{:query
|
||||
(str "SELECT ID, NAME, CATEGORY_ID, LATITUDE, LONGITUDE, PRICE "
|
||||
"FROM VENUES "
|
||||
"WHERE CATEGORY_ID = {{cat}}")
|
||||
{:query
|
||||
(str "SELECT ID, NAME, CATEGORY_ID, LATITUDE, LONGITUDE, PRICE "
|
||||
"FROM VENUES "
|
||||
"WHERE CATEGORY_ID = {{cat}}")
|
||||
|
||||
:template_tags
|
||||
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
|
||||
:template_tags
|
||||
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
|
||||
:remappings {:cat ["variable" ["template-tag" "cat"]]}}}
|
||||
:attributes {"cat" 50}}
|
||||
(is (=? (expected-cols)
|
||||
@@ -619,13 +619,13 @@
|
||||
(testing (str "If columns are removed/reordered we should still merge in metadata for the columns we're "
|
||||
"able to match from the original Table")
|
||||
(met/with-gtaps! {:gtaps {:venues {:query (mt/native-query
|
||||
{:query
|
||||
(str "SELECT NAME, ID, LONGITUDE, PRICE "
|
||||
"FROM VENUES "
|
||||
"WHERE CATEGORY_ID = {{cat}}")
|
||||
{:query
|
||||
(str "SELECT NAME, ID, LONGITUDE, PRICE "
|
||||
"FROM VENUES "
|
||||
"WHERE CATEGORY_ID = {{cat}}")
|
||||
|
||||
:template_tags
|
||||
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
|
||||
:template_tags
|
||||
{:cat {:name "cat" :display_name "cat" :type "number" :required true}}})
|
||||
:remappings {:cat ["variable" ["template-tag" "cat"]]}}}
|
||||
:attributes {"cat" 50}}
|
||||
(let [[id-col name-col _ _ longitude-col price-col] (expected-cols)]
|
||||
@@ -636,15 +636,15 @@
|
||||
(testing "Should be able to use a Saved Question with no source Metadata as a GTAP (EE #525)"
|
||||
(met/with-gtaps! (mt/$ids
|
||||
{:gtaps {:venues {:query (mt/native-query
|
||||
{:query (str "SELECT DISTINCT VENUES.* "
|
||||
"FROM VENUES "
|
||||
"LEFT JOIN CHECKINS"
|
||||
" ON CHECKINS.VENUE_ID = VENUES.ID "
|
||||
"WHERE CHECKINS.USER_ID IN ({{sandbox}})")
|
||||
:template-tags {"sandbox"
|
||||
{:name "sandbox"
|
||||
:display-name "Sandbox"
|
||||
:type :text}}})
|
||||
{:query (str "SELECT DISTINCT VENUES.* "
|
||||
"FROM VENUES "
|
||||
"LEFT JOIN CHECKINS"
|
||||
" ON CHECKINS.VENUE_ID = VENUES.ID "
|
||||
"WHERE CHECKINS.USER_ID IN ({{sandbox}})")
|
||||
:template-tags {"sandbox"
|
||||
{:name "sandbox"
|
||||
:display-name "Sandbox"
|
||||
:type :text}}})
|
||||
:remappings {"user_id" [:variable [:template-tag "sandbox"]]}}
|
||||
:checkins {:remappings {"user_id" [:dimension $checkins.user_id]}}}
|
||||
:attributes {"user_id" 1}})
|
||||
@@ -667,13 +667,13 @@
|
||||
(testing "Should work with SQL queries that return less columns than there were in the original Table\n"
|
||||
(met/with-gtaps! (mt/$ids
|
||||
{:gtaps {:venues {:query (mt/native-query
|
||||
{:query (str "SELECT * "
|
||||
"FROM VENUES "
|
||||
"WHERE VENUES.ID IN ({{sandbox}})")
|
||||
:template-tags {"sandbox"
|
||||
{:name "sandbox"
|
||||
:display-name "Sandbox"
|
||||
:type :text}}})
|
||||
{:query (str "SELECT * "
|
||||
"FROM VENUES "
|
||||
"WHERE VENUES.ID IN ({{sandbox}})")
|
||||
:template-tags {"sandbox"
|
||||
{:name "sandbox"
|
||||
:display-name "Sandbox"
|
||||
:type :text}}})
|
||||
:remappings {"venue_id" [:variable [:template-tag "sandbox"]]}}
|
||||
:checkins {}}
|
||||
:attributes {"venue_id" 1}})
|
||||
@@ -709,11 +709,11 @@
|
||||
(defn- do-with-sql-gtap! [sql f]
|
||||
(met/with-gtaps! (mt/$ids
|
||||
{:gtaps {:venues {:query (mt/native-query
|
||||
{:query sql
|
||||
:template-tags {"sandbox"
|
||||
{:name "sandbox"
|
||||
:display-name "Sandbox"
|
||||
:type :text}}})
|
||||
{:query sql
|
||||
:template-tags {"sandbox"
|
||||
{:name "sandbox"
|
||||
:display-name "Sandbox"
|
||||
:type :text}}})
|
||||
:remappings {"venue_id" [:variable [:template-tag "sandbox"]]}}
|
||||
:checkins {}}
|
||||
:attributes {"venue_id" 1}})
|
||||
@@ -1041,18 +1041,18 @@
|
||||
(pr-str orders-gtap-card-has-metadata?)
|
||||
(pr-str products-gtap-card-has-metadata?))
|
||||
(met/with-gtaps! {:gtaps {:orders {:query (mt/native-query
|
||||
{:query "SELECT * FROM ORDERS WHERE USER_ID={{uid}} AND TOTAL > 10"
|
||||
:template-tags {"uid" {:display-name "User ID"
|
||||
:id "1"
|
||||
:name "uid"
|
||||
:type :number}}})
|
||||
{:query "SELECT * FROM ORDERS WHERE USER_ID={{uid}} AND TOTAL > 10"
|
||||
:template-tags {"uid" {:display-name "User ID"
|
||||
:id "1"
|
||||
:name "uid"
|
||||
:type :number}}})
|
||||
:remappings {"user_id" [:variable [:template-tag "uid"]]}}
|
||||
:products {:query (mt/native-query
|
||||
{:query "SELECT * FROM PRODUCTS WHERE CATEGORY={{cat}} AND PRICE > 10"
|
||||
:template-tags {"cat" {:display-name "Category"
|
||||
:id "2"
|
||||
:name "cat"
|
||||
:type :text}}})
|
||||
{:query "SELECT * FROM PRODUCTS WHERE CATEGORY={{cat}} AND PRICE > 10"
|
||||
:template-tags {"cat" {:display-name "Category"
|
||||
:id "2"
|
||||
:name "cat"
|
||||
:type :text}}})
|
||||
:remappings {"user_cat" [:variable [:template-tag "cat"]]}}}
|
||||
:attributes {"user_id" "1", "user_cat" "Widget"}}
|
||||
(when orders-gtap-card-has-metadata?
|
||||
@@ -1551,3 +1551,31 @@
|
||||
[3]]
|
||||
(mt/rows
|
||||
(qp/process-query (query))))))))
|
||||
|
||||
(deftest datetime-extraction-to-int-test
|
||||
(testing "Downloading CSV/XLSX for query with COUNT grouped by day of month should work for sandboxed users (#UXW-660)"
|
||||
(met/with-gtaps! {:gtaps {:orders {:remappings {"user_id" ["variable" [:field (mt/id :orders :user_id) nil]]}}},
|
||||
:attributes {"user_id" 1}}
|
||||
(let [query (mt/mbql-query orders
|
||||
{:aggregation [[:count]]
|
||||
:breakout [[:field (mt/id :orders :created_at) {:effective-type :type/Integer
|
||||
:base-type :type/DateTime
|
||||
:temporal-unit :day-of-month}]]
|
||||
:order-by [[:asc [:field (mt/id :orders :created_at) {:effective-type :type/Integer
|
||||
:base-type :type/DateTime
|
||||
:temporal-unit :day-of-month}]]]
|
||||
:limit 5})]
|
||||
(testing "Query should complete successfully with normal processing"
|
||||
(let [result (qp/process-query query)]
|
||||
(is (= :completed (:status result)))
|
||||
(is (seq (mt/rows result)))))
|
||||
(testing "Query should complete successfully when downloading as CSV"
|
||||
(let [result (streaming.test-util/process-query-basic-streaming :csv query ["Created At: Day of month" "Count"])]
|
||||
(is (seq result))
|
||||
(testing "Results should include integer day-of-month values and counts"
|
||||
(is (every? (fn [[day count]]
|
||||
(and (string? day)
|
||||
(string? count)
|
||||
(let [day-int (parse-long day)]
|
||||
(<= 1 day-int 31))))
|
||||
(next result))))))))))
|
||||
|
||||
@@ -702,14 +702,14 @@
|
||||
:collection_id coll2-id
|
||||
:dataset_query
|
||||
(mt/native-query
|
||||
{:query "select {{snippet}}"
|
||||
:template-tags {"snippet"
|
||||
{:display-name "Snippet 1",
|
||||
:id s1-eid,
|
||||
:name "snippet 1",
|
||||
:snippet-id s1-id,
|
||||
:snippet-name "snip",
|
||||
:type "snippet"}}})}]
|
||||
{:query "select {{snippet}}"
|
||||
:template-tags {"snippet"
|
||||
{:display-name "Snippet 1",
|
||||
:id s1-eid,
|
||||
:name "snippet 1",
|
||||
:snippet-id s1-id,
|
||||
:snippet-name "snip",
|
||||
:type "snippet"}}})}]
|
||||
(testing "native query snippets"
|
||||
(testing "can belong to :snippets collections"
|
||||
(let [ser (serdes/extract-one "NativeQuerySnippet" {} (t2/select-one :model/NativeQuerySnippet :id s1-id))]
|
||||
|
||||
@@ -783,3 +783,39 @@
|
||||
(is (= "Transforms are not supported on databases with DB routing enabled."
|
||||
(mt/user-http-request :crowberto :put 400 (format "ee/transform/%s" (:id transform))
|
||||
(assoc transform :name "Gadget Products 2")))))))))))
|
||||
|
||||
(deftest transform-revisions-test
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)
|
||||
(mt/with-premium-features #{:transforms}
|
||||
(mt/dataset transforms-dataset/transforms-test
|
||||
(with-transform-cleanup! [table-name "transform_revisions_test"]
|
||||
(let [test-transform-revisions (fn [action url req exp-revisions]
|
||||
(let [transform (mt/user-http-request :crowberto action 200 url req)
|
||||
transform-id (:id transform)]
|
||||
(is (= exp-revisions (t2/count :model/Revision :model "Transform"
|
||||
:model_id transform-id)))
|
||||
(let [revision (t2/select-one :model/Revision :model "Transform"
|
||||
:model_id transform-id :most_recent true)
|
||||
rev-transform (:object revision)
|
||||
removed #{:id :entity_id :created_at :updated_at}]
|
||||
(is (every? #(not (contains? rev-transform %)) removed))
|
||||
;; CI tries comparing "=" with := which fails
|
||||
(is (=? (dissoc rev-transform :source) transform)))
|
||||
transform-id))
|
||||
gadget-req {:name "Gadget Products"
|
||||
:description "The gadget products"
|
||||
:source {:type "query"
|
||||
:query (make-query "Gadget")}
|
||||
:target {:type "table"
|
||||
:schema (get-test-schema)
|
||||
:name table-name}}
|
||||
transform-id (test-transform-revisions :post "ee/transform" gadget-req 1)
|
||||
widget-req {:name "Widget Products"
|
||||
:description "The widget products"
|
||||
:source {:type "query"
|
||||
:query (make-query "Widget")}
|
||||
:tag_ids [4]
|
||||
:target {:type "table"
|
||||
:schema (get-test-schema)
|
||||
:name table-name}}]
|
||||
(test-transform-revisions :put (str "ee/transform/" transform-id) widget-req 2)))))))
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
(ns metabase-enterprise.transforms.revision-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.revisions.models.revision :as revision]))
|
||||
|
||||
(deftest transform-serialize-instance-test
|
||||
(testing "serialize-instance for :model/Transform excludes the correct columns"
|
||||
(let [transform {:id 1
|
||||
:entity_id "wyQv6yHnXS-IqPrYm1osQ"
|
||||
:created_at "2025-09-30T00:00:00Z"
|
||||
:updated_at "2025-09-30T00:00:00Z"
|
||||
:name "Test Transform"
|
||||
:description "A test transform"
|
||||
:source {:type :query
|
||||
:query {:database 1
|
||||
:type :query
|
||||
:query {:source-table 2}}}
|
||||
:target {:type :table
|
||||
:name "transformed_table"
|
||||
:schema "public"
|
||||
:database 1}}
|
||||
exp-serialized (dissoc transform :id :entity_id :created_at :updated_at)]
|
||||
(is (= (revision/serialize-instance :model/Transform nil transform)
|
||||
exp-serialized)))))
|
||||
@@ -29,21 +29,22 @@
|
||||
"Execute a Python transform with the given code and tables"
|
||||
[{:keys [code tables]}]
|
||||
(with-open [shared-storage-ref (s3/open-shared-storage! (or tables {}))]
|
||||
(let [server-url (transforms-python.settings/python-runner-url)
|
||||
cancel-chan (a/promise-chan)
|
||||
table-name->id (or tables {})
|
||||
_ (python-runner/copy-tables-to-s3! {:run-id test-id
|
||||
:shared-storage @shared-storage-ref
|
||||
:table-name->id table-name->id
|
||||
:cancel-chan cancel-chan})
|
||||
response (python-runner/execute-python-code-http-call! {:server-url server-url
|
||||
:code code
|
||||
:run-id test-id
|
||||
:table-name->id table-name->id
|
||||
:shared-storage @shared-storage-ref})
|
||||
{:keys [output output-manifest events]} (python-runner/read-output-objects @shared-storage-ref)]
|
||||
(let [server-url (transforms-python.settings/python-runner-url)
|
||||
cancel-chan (a/promise-chan)
|
||||
table-name->id (or tables {})
|
||||
_ (python-runner/copy-tables-to-s3! {:run-id test-id
|
||||
:shared-storage @shared-storage-ref
|
||||
:table-name->id table-name->id
|
||||
:cancel-chan cancel-chan})
|
||||
response (python-runner/execute-python-code-http-call! {:server-url server-url
|
||||
:code code
|
||||
:run-id test-id
|
||||
:table-name->id table-name->id
|
||||
:shared-storage @shared-storage-ref})
|
||||
events (python-runner/read-events @shared-storage-ref)
|
||||
output-manifest (python-runner/read-output-manifest @shared-storage-ref)]
|
||||
(merge (:body response)
|
||||
{:output output
|
||||
{:output (when-some [in (python-runner/open-output @shared-storage-ref)] (with-open [in in] (slurp in)))
|
||||
:output-manifest output-manifest
|
||||
:stdout (->> events (filter #(= "stdout" (:stream %))) (map :message) (str/join "\n"))
|
||||
:stderr (->> events (filter #(= "stderr" (:stream %))) (map :message) (str/join "\n"))}))))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
[metabase-enterprise.transforms.test-util :as transforms.tu :refer [with-transform-cleanup!]]
|
||||
[metabase-enterprise.transforms.util :as transforms.util]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.util :as test.util]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.util.concurrent CountDownLatch)))
|
||||
@@ -92,3 +93,37 @@
|
||||
|
||||
(is (= [[1 "a"] [2 "b"] [3 "c"]] (transforms.tu/table-rows table-name))
|
||||
"Table should contain the expected data after swap")))))))))))
|
||||
|
||||
(deftest python-transform-timeout-status-test
|
||||
(testing "Python transform execution sets correct timeout status when script times out"
|
||||
(mt/test-drivers #{:postgres}
|
||||
(mt/with-premium-features #{:transforms-python}
|
||||
(mt/dataset transforms-dataset/transforms-test
|
||||
(let [schema (t2/select-one-fn :schema :model/Table (mt/id :transforms_products))]
|
||||
(with-transform-cleanup! [target {:type "table"
|
||||
:schema schema
|
||||
:name "timeout_test"}]
|
||||
(test.util/with-temporary-setting-values [python-runner-timeout-seconds 5]
|
||||
(let [long-running-code (str "import time\n"
|
||||
"import pandas as pd\n"
|
||||
"\n"
|
||||
"def transform():\n"
|
||||
" time.sleep(10) # Sleep longer than timeout\n"
|
||||
" return pd.DataFrame({'result': ['should_not_reach_here']})")
|
||||
transform-def {:name "Python Transform Timeout Test"
|
||||
:source {:type "python"
|
||||
:source-tables {}
|
||||
:body long-running-code}
|
||||
:target (assoc target :database (mt/id))}]
|
||||
(mt/with-temp [:model/Transform transform transform-def]
|
||||
(let [{:keys [run_id]} (try
|
||||
(transforms-python.execute/execute-python-transform! transform {:run-method :manual})
|
||||
(catch Exception _
|
||||
;; We expect this to fail due to timeout
|
||||
{:run_id (t2/select-one-fn :id :model/TransformRun
|
||||
:transform_id (:id transform)
|
||||
{:order-by [[:start_time :desc]]})}))
|
||||
run-status (t2/select-one-fn :status :model/TransformRun :id run_id)]
|
||||
(testing "Transform run should have timeout status"
|
||||
(is (= :timeout run-status)
|
||||
"Transform run status should be :timeout when Python script times out")))))))))))))
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.data.interface :as tx]
|
||||
[metabase.test.data.sql :as sql.tx]
|
||||
[metabase.test.util :as tu]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]
|
||||
[toucan2.core :as t2]))
|
||||
@@ -69,10 +70,11 @@
|
||||
:run-id test-id
|
||||
:table-name->id table-name->id
|
||||
:shared-storage @shared-storage-ref})
|
||||
{:keys [output output-manifest events]} (python-runner/read-output-objects @shared-storage-ref)]
|
||||
events (python-runner/read-events @shared-storage-ref)
|
||||
output-manifest (python-runner/read-output-manifest @shared-storage-ref)]
|
||||
;; not sure about munging this all together but its what tests expect for now
|
||||
(merge (:body response)
|
||||
{:output output
|
||||
{:output (when-some [in (python-runner/open-output @shared-storage-ref)] (with-open [in in] (slurp in)))
|
||||
:output-manifest output-manifest
|
||||
:stdout (->> events (filter #(= "stdout" (:stream %))) (map :message) (str/join "\n"))
|
||||
:stderr (->> events (filter #(= "stderr" (:stream %))) (map :message) (str/join "\n"))}))))
|
||||
@@ -493,3 +495,18 @@
|
||||
|
||||
;; cleanup
|
||||
(driver/drop-table! driver db-id qualified-table-name)))))
|
||||
|
||||
(deftest python-runner-timeout-test
|
||||
(testing "Python script execution respects timeout setting"
|
||||
(mt/with-premium-features #{:transforms-python}
|
||||
(tu/with-temporary-setting-values [python-runner-timeout-seconds 5]
|
||||
(let [long-running-code (str "import time\n"
|
||||
"import pandas as pd\n"
|
||||
"\n"
|
||||
"def transform():\n"
|
||||
" time.sleep(10) # Sleep longer than timeout\n"
|
||||
" return pd.DataFrame({'result': ['should_not_reach_here']})")
|
||||
result (execute! {:code long-running-code})]
|
||||
(testing "Script should timeout after 5 seconds"
|
||||
(is (contains? result :error))
|
||||
(is (str/includes? (:error result) "timeout"))))))))
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
(java.net ConnectException)
|
||||
(java.nio.file Files)
|
||||
(java.nio.file.attribute FileAttribute)
|
||||
(software.amazon.awssdk.services.s3 S3Client)
|
||||
(software.amazon.awssdk.services.s3.model NoSuchKeyException)))
|
||||
(software.amazon.awssdk.services.s3 S3Client)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -43,7 +42,7 @@
|
||||
key (str "test-object-" (random-uuid) ".txt")
|
||||
body (str "Hello, S3! My secret is:" (random-uuid))]
|
||||
|
||||
(is (thrown? NoSuchKeyException (s3/read-to-string s3-client bucket key)))
|
||||
(is (nil? (s3/read-to-string s3-client bucket key)))
|
||||
(is (= :default (s3/read-to-string s3-client bucket key :default)))
|
||||
|
||||
(let [tmp-file (Files/createTempFile "s3-test" ".txt" (into-array FileAttribute []))]
|
||||
@@ -57,7 +56,7 @@
|
||||
|
||||
(s3/delete s3-client bucket key)
|
||||
|
||||
(is (thrown? NoSuchKeyException (s3/read-to-string s3-client bucket key)))
|
||||
(is (nil? (s3/read-to-string s3-client bucket key)))
|
||||
(is (= :default (s3/read-to-string s3-client bucket key :default)))
|
||||
|
||||
(finally
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.SqlParametersList {
|
||||
/* Reset ResponsiveList margins */
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useSdkQuestionContext } from "embedding-sdk-bundle/components/private/SdkQuestion/context";
|
||||
import { ResponsiveParametersList } from "metabase/query_builder/components/ResponsiveParametersList";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type { ParameterId } from "metabase-types/api";
|
||||
|
||||
import SqlParametersListS from "./SqlParametersList.module.css";
|
||||
|
||||
/**
|
||||
* Parameters list for SQL questions
|
||||
*
|
||||
* @function
|
||||
* @category InteractiveQuestion
|
||||
*/
|
||||
export const SqlParametersList = () => {
|
||||
const {
|
||||
question,
|
||||
originalQuestion,
|
||||
parameterValues,
|
||||
updateParameterValues,
|
||||
hiddenParameters,
|
||||
} = useSdkQuestionContext();
|
||||
|
||||
const isNativeQuestion = useMemo(() => {
|
||||
if (!question) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { isNative } = Lib.queryDisplayInfo(question.query());
|
||||
|
||||
return isNative;
|
||||
}, [question]);
|
||||
|
||||
const parameters = useMemo(() => {
|
||||
if (!question || !originalQuestion) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const originalParameters = originalQuestion.card().parameters ?? [];
|
||||
|
||||
return question
|
||||
.parameters()
|
||||
.filter(
|
||||
({ id, slug }) =>
|
||||
originalParameters.find(
|
||||
(originalParameter) => originalParameter.id === id,
|
||||
) && !hiddenParameters?.includes(slug),
|
||||
);
|
||||
}, [question, originalQuestion, hiddenParameters]);
|
||||
|
||||
if (!question || !isNativeQuestion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setParameterValue = (parameterId: ParameterId, value: string) => {
|
||||
const nextParameterValues = {
|
||||
...parameterValues,
|
||||
[parameterId]: value,
|
||||
};
|
||||
|
||||
updateParameterValues(nextParameterValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveParametersList
|
||||
className={SqlParametersListS.SqlParametersList}
|
||||
cardId={question.id()}
|
||||
dashboardId={question.dashboardId() ?? undefined}
|
||||
parameters={parameters}
|
||||
setParameterValue={setParameterValue}
|
||||
enableParameterRequiredBehavior
|
||||
commitImmediately={false}
|
||||
isSortable={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SqlParametersList";
|
||||
@@ -14,3 +14,4 @@ export * from "./Title";
|
||||
export * from "./Visualization";
|
||||
export * from "./VisualizationButton";
|
||||
export * from "./QuestionSettings";
|
||||
export * from "./SqlParametersList";
|
||||
|
||||
@@ -37,6 +37,7 @@ const _BadgeListItem = (
|
||||
onRemoveItem?.();
|
||||
}}
|
||||
className={S.BadgeListRemoveButton}
|
||||
data-testid="badge-remove-button"
|
||||
>
|
||||
<Icon name="close" c="var(--mb-color-text-brand)" size={10} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -45,6 +45,7 @@ export const SdkQuestionProvider = ({
|
||||
entityTypes,
|
||||
targetCollection,
|
||||
initialSqlParameters,
|
||||
hiddenParameters,
|
||||
withDownloads,
|
||||
targetDashboardId,
|
||||
backToDashboard,
|
||||
@@ -94,6 +95,7 @@ export const SdkQuestionProvider = ({
|
||||
const {
|
||||
question,
|
||||
originalQuestion,
|
||||
parameterValues,
|
||||
|
||||
queryResults,
|
||||
|
||||
@@ -104,6 +106,7 @@ export const SdkQuestionProvider = ({
|
||||
replaceQuestion,
|
||||
loadAndQueryQuestion,
|
||||
updateQuestion,
|
||||
updateParameterValues,
|
||||
navigateToNewCard,
|
||||
} = useLoadQuestion({
|
||||
questionId,
|
||||
@@ -144,6 +147,7 @@ export const SdkQuestionProvider = ({
|
||||
queryQuestion,
|
||||
replaceQuestion,
|
||||
updateQuestion,
|
||||
updateParameterValues,
|
||||
navigateToNewCard:
|
||||
userNavigateToNewCard !== undefined
|
||||
? userNavigateToNewCard
|
||||
@@ -151,6 +155,7 @@ export const SdkQuestionProvider = ({
|
||||
plugins,
|
||||
question,
|
||||
originalQuestion,
|
||||
parameterValues,
|
||||
queryResults,
|
||||
mode,
|
||||
onSave: handleSave,
|
||||
@@ -160,6 +165,7 @@ export const SdkQuestionProvider = ({
|
||||
withDownloads,
|
||||
onRun,
|
||||
backToDashboard,
|
||||
hiddenParameters,
|
||||
onVisualizationChange,
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ type SdkQuestionConfig = {
|
||||
**/
|
||||
initialSqlParameters?: SqlParameterValues;
|
||||
|
||||
/**
|
||||
* A list of parameters to hide.
|
||||
**/
|
||||
hiddenParameters?: string[];
|
||||
|
||||
/**
|
||||
* Enables the ability to download results in the interactive question.
|
||||
*/
|
||||
@@ -128,6 +133,7 @@ export type SdkQuestionContextType = Omit<
|
||||
| "targetCollection"
|
||||
| "withDownloads"
|
||||
| "backToDashboard"
|
||||
| "hiddenParameters"
|
||||
| "onVisualizationChange"
|
||||
> & {
|
||||
plugins: SdkQuestionConfig["componentPlugins"] | null;
|
||||
|
||||
@@ -246,7 +246,12 @@ export const SdkQuestionDefaultView = ({
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box className={InteractiveQuestionS.Main} p="sm" w="100%" h="100%">
|
||||
<Box
|
||||
className={cx(InteractiveQuestionS.Main, "sdk-question-main")}
|
||||
p="sm"
|
||||
w="100%"
|
||||
h="100%"
|
||||
>
|
||||
<Box className={InteractiveQuestionS.Content}>
|
||||
{isEditorOpen ? (
|
||||
<Editor onApply={closeEditor} />
|
||||
|
||||
@@ -109,7 +109,9 @@ export const SdkUsageProblemBanner = ({
|
||||
<ExternalLink role="link" href={problem.documentationUrl}>
|
||||
<Button
|
||||
fz="sm"
|
||||
rightSection={<Icon name="external" size="1rem" />}
|
||||
rightSection={
|
||||
<Icon aria-hidden name="external" size="1rem" />
|
||||
}
|
||||
ff={fontFamily}
|
||||
className={S.DocsButton}
|
||||
size="md"
|
||||
|
||||
@@ -8,7 +8,10 @@ import { mockSettings } from "__support__/settings";
|
||||
import { screen, within } from "__support__/ui";
|
||||
import { renderWithSDKProviders } from "embedding-sdk-bundle/test/__support__/ui";
|
||||
import { createMockSdkConfig } from "embedding-sdk-bundle/test/mocks/config";
|
||||
import { createMockSdkState } from "embedding-sdk-bundle/test/mocks/state";
|
||||
import {
|
||||
createMockSdkState,
|
||||
createMockTokenState,
|
||||
} from "embedding-sdk-bundle/test/mocks/state";
|
||||
import {
|
||||
createMockSettings,
|
||||
createMockTokenFeatures,
|
||||
@@ -55,10 +58,18 @@ const setup = (options: Options) => {
|
||||
"enable-embedding-simple": options.isSimpleEmbeddingEnabled ?? true,
|
||||
});
|
||||
|
||||
const MINUTE = 60;
|
||||
const state = createMockState({
|
||||
settings: mockSettings(settingValues),
|
||||
currentUser: TEST_USER,
|
||||
sdk: createMockSdkState(),
|
||||
sdk: createMockSdkState({
|
||||
token: createMockTokenState({
|
||||
token: {
|
||||
id: "123",
|
||||
exp: Math.round(Date.now() / 1000) + 10 * MINUTE,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
setupSettingsEndpoints([]);
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
createMockApiKeyConfig,
|
||||
createMockSdkConfig,
|
||||
} from "embedding-sdk-bundle/test/mocks/config";
|
||||
import { createMockSdkState } from "embedding-sdk-bundle/test/mocks/state";
|
||||
import {
|
||||
createMockSdkState,
|
||||
createMockTokenState,
|
||||
} from "embedding-sdk-bundle/test/mocks/state";
|
||||
import type { MetabaseAuthConfig } from "embedding-sdk-bundle/types";
|
||||
import {
|
||||
createMockSettings,
|
||||
@@ -37,9 +40,10 @@ interface Options {
|
||||
hasEmbeddingFeature?: boolean;
|
||||
isEmbeddingSdkEnabled?: boolean;
|
||||
isDevelopmentMode?: boolean;
|
||||
hasExpirationClaim?: boolean;
|
||||
}
|
||||
|
||||
const setup = (options: Options) => {
|
||||
const setup = ({ hasExpirationClaim = true, ...options }: Options) => {
|
||||
const tokenFeatures = createMockTokenFeatures({
|
||||
embedding_sdk: options.hasEmbeddingFeature ?? true,
|
||||
development_mode: options.isDevelopmentMode ?? false,
|
||||
@@ -50,10 +54,20 @@ const setup = (options: Options) => {
|
||||
"enable-embedding-sdk": options.isEmbeddingSdkEnabled ?? true,
|
||||
});
|
||||
|
||||
const MINUTE = 60;
|
||||
const state = createMockState({
|
||||
settings: mockSettings(settingValues),
|
||||
currentUser: TEST_USER,
|
||||
sdk: createMockSdkState(),
|
||||
sdk: createMockSdkState({
|
||||
token: createMockTokenState({
|
||||
token: {
|
||||
id: "123",
|
||||
exp: hasExpirationClaim
|
||||
? Math.round(Date.now() / 1000) + 10 * MINUTE
|
||||
: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
setupSettingsEndpoints([]);
|
||||
@@ -98,7 +112,7 @@ describe("SdkUsageProblemDisplay", () => {
|
||||
).toBeInTheDocument();
|
||||
|
||||
const docsLink = within(card).getByRole("link", {
|
||||
name: /Documentation/,
|
||||
name: "Documentation",
|
||||
});
|
||||
|
||||
expect(docsLink).toHaveAttribute(
|
||||
@@ -127,12 +141,12 @@ describe("SdkUsageProblemDisplay", () => {
|
||||
).toBeInTheDocument();
|
||||
|
||||
const docsLink = within(card).getByRole("link", {
|
||||
name: /Documentation/,
|
||||
name: "Documentation",
|
||||
});
|
||||
|
||||
expect(docsLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://www.metabase.com/docs/latest/embedding/sdk/authentication#authenticating-people-from-your-server",
|
||||
"https://www.metabase.com/docs/latest/embedding/sdk/authentication#2-add-a-new-endpoint-to-your-backend-to-handle-authentication",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -180,7 +194,7 @@ describe("SdkUsageProblemDisplay", () => {
|
||||
).toBeInTheDocument();
|
||||
|
||||
const docsLink = within(card).getByRole("link", {
|
||||
name: /Documentation/,
|
||||
name: "Documentation",
|
||||
});
|
||||
|
||||
expect(docsLink).toHaveAttribute(
|
||||
@@ -211,7 +225,7 @@ describe("SdkUsageProblemDisplay", () => {
|
||||
).toBeInTheDocument();
|
||||
|
||||
const docsLink = within(card).getByRole("link", {
|
||||
name: /Documentation/,
|
||||
name: "Documentation",
|
||||
});
|
||||
|
||||
expect(docsLink).toHaveAttribute(
|
||||
@@ -220,6 +234,38 @@ describe("SdkUsageProblemDisplay", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should show a warning when JWT token does not contain the "exp" claim', async () => {
|
||||
setup({
|
||||
authConfig: createMockSdkConfig(),
|
||||
isEmbeddingSdkEnabled: true,
|
||||
isDevelopmentMode: false,
|
||||
hasExpirationClaim: false,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByTestId(PROBLEM_INDICATOR_TEST_ID));
|
||||
|
||||
const card = screen.getByTestId(PROBLEM_CARD_TEST_ID);
|
||||
|
||||
expect(
|
||||
within(card).getByText("This embed is powered by the Metabase SDK."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
within(card).getByText(
|
||||
`The JWT token is missing the "exp" (expiration) claim. We will disallow tokens without "exp" in a future release. Please add "exp" to the token payload.`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const docsLink = within(card).getByRole("link", {
|
||||
name: "Documentation",
|
||||
});
|
||||
|
||||
expect(docsLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://www.metabase.com/docs/latest/embedding/sdk/authentication#2-add-a-new-endpoint-to-your-backend-to-handle-authentication",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the problem when 'hide' is clicked", async () => {
|
||||
setup({
|
||||
authConfig: createMockSdkConfig(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useSdkUsageProblem } from "embedding-sdk-bundle/hooks/private/use-sdk-usage-problem";
|
||||
import { getSessionTokenState } from "embedding-sdk-bundle/store/selectors";
|
||||
import { useSdkSelector } from "embedding-sdk-bundle/store/use-sdk-selector";
|
||||
import type { MetabaseAuthConfig } from "embedding-sdk-bundle/types";
|
||||
import { EMBEDDING_SDK_PORTAL_ROOT_ELEMENT_ID } from "metabase/embedding-sdk/config";
|
||||
import { Box, Portal } from "metabase/ui";
|
||||
@@ -15,7 +17,13 @@ export const SdkUsageProblemDisplay = ({
|
||||
authConfig,
|
||||
allowConsoleLog,
|
||||
}: Props) => {
|
||||
const usageProblem = useSdkUsageProblem({ authConfig, allowConsoleLog });
|
||||
const session = useSdkSelector((state) => getSessionTokenState(state).token);
|
||||
|
||||
const usageProblem = useSdkUsageProblem({
|
||||
authConfig,
|
||||
allowConsoleLog,
|
||||
session,
|
||||
});
|
||||
|
||||
if (!usageProblem) {
|
||||
return null;
|
||||
|
||||
@@ -17,6 +17,7 @@ const propsSchema: Yup.SchemaOf<InteractiveQuestionProps> = Yup.object({
|
||||
entityTypes: Yup.mixed().optional(),
|
||||
height: Yup.mixed().optional(),
|
||||
initialSqlParameters: Yup.mixed().optional(),
|
||||
hiddenParameters: Yup.mixed().optional(),
|
||||
isSaveEnabled: Yup.mixed().optional(),
|
||||
onBeforeSave: Yup.mixed().optional(),
|
||||
onNavigateBack: Yup.mixed().optional(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
QuestionVisualization,
|
||||
SaveButton,
|
||||
SdkSaveQuestionForm,
|
||||
SqlParametersList,
|
||||
Summarize,
|
||||
SummarizeDropdown,
|
||||
Title,
|
||||
@@ -67,6 +68,7 @@ export type InteractiveQuestionComponents = {
|
||||
BreakoutDropdown: typeof BreakoutDropdown;
|
||||
DownloadWidget: typeof DownloadWidget;
|
||||
DownloadWidgetDropdown: typeof DownloadWidgetDropdown;
|
||||
SqlParametersList: typeof SqlParametersList;
|
||||
};
|
||||
|
||||
export const _InteractiveQuestion = (props: InteractiveQuestionProps) => (
|
||||
@@ -97,6 +99,7 @@ const subComponents: InteractiveQuestionComponents = {
|
||||
DownloadWidget: DownloadWidget,
|
||||
DownloadWidgetDropdown: DownloadWidgetDropdown,
|
||||
VisualizationButton: VisualizationButton,
|
||||
SqlParametersList: SqlParametersList,
|
||||
};
|
||||
|
||||
export const InteractiveQuestion = Object.assign(
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
/* TODO: rtl support */
|
||||
|
||||
@keyframes chat-bar-slide-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 2rem);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
transform-origin: top;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
max-width: 41.5rem;
|
||||
width: 100%;
|
||||
border: 1px solid var(--mb-color-brand);
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.innerContainer {
|
||||
padding: 0 1rem;
|
||||
border-radius: 22px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
border: solid 1.5px transparent;
|
||||
background: var(--mb-color-bg-white);
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.innerContainer::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(to right, #2e96f1, #d932f4);
|
||||
z-index: -1;
|
||||
margin: -1.5px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.innerContainerLoading {
|
||||
background: var(--mb-color-bg-light);
|
||||
}
|
||||
|
||||
.innerContainerExpanded {
|
||||
padding: 1rem;
|
||||
align-items: flex-start;
|
||||
height: 8rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
caret-color: var(--mb-color-brand);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
caret-color: var(--mb-color-brand);
|
||||
padding: 0.5rem 0;
|
||||
line-height: 1.7;
|
||||
color: var(--mb-color-text-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.textareaExpanded {
|
||||
textarea {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.textareaLoading {
|
||||
textarea {
|
||||
font-weight: bold;
|
||||
|
||||
&:disabled {
|
||||
opacity: 1;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* nesting the style above keeps it from getting applied */
|
||||
.textareaLoading textarea::placeholder,
|
||||
.textareaLoading textarea:disabled::placeholder {
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
|
||||
@keyframes responses-slide-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(0, -0.5rem);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
transform-origin: top;
|
||||
}
|
||||
}
|
||||
|
||||
.responses {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
margin-top: 0.25rem;
|
||||
pointer-events: none; /* allow user to click through transparent area behind repsonses */
|
||||
animation: responses-slide-in 150ms ease forwards;
|
||||
transition-property: transform, opacity;
|
||||
}
|
||||
|
||||
.response {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
background: var(--mb-color-brand);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
border-radius: 1rem;
|
||||
color: white;
|
||||
pointer-events: auto;
|
||||
margin-top: 0.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.responseMessage {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
button.responseDismissBtn {
|
||||
color: #cbe2f7;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import cx from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSdkDispatch } from "embedding-sdk-bundle/store";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Icon,
|
||||
Loader,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "metabase/ui";
|
||||
import {
|
||||
useMetabotAgent,
|
||||
useMetabotChatHandlers,
|
||||
} from "metabase-enterprise/metabot/hooks";
|
||||
import {
|
||||
cancelInflightAgentRequests,
|
||||
resetConversationId,
|
||||
} from "metabase-enterprise/metabot/state";
|
||||
|
||||
import Styles from "./MetabotChatEmbedding.module.css";
|
||||
|
||||
const MIN_INPUT_HEIGHT = 42;
|
||||
|
||||
export const MetabotChatEmbedding = () => {
|
||||
const metabot = useMetabotAgent();
|
||||
const { handleSubmitInput, handleResetInput } = useMetabotChatHandlers();
|
||||
|
||||
const resetInput = () => {
|
||||
handleResetInput();
|
||||
setInputExpanded(false);
|
||||
};
|
||||
|
||||
const startNewConversation = () => {
|
||||
resetInput();
|
||||
metabot.resetConversation();
|
||||
};
|
||||
|
||||
const [inputExpanded, setInputExpanded] = useState(false);
|
||||
const handleMaybeExpandInput = () => {
|
||||
const textarea = metabot.promptInputRef?.current;
|
||||
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMultiRow = textarea.scrollHeight > MIN_INPUT_HEIGHT;
|
||||
if (inputExpanded !== isMultiRow) {
|
||||
setInputExpanded(isMultiRow);
|
||||
}
|
||||
// keep scrolled to bottom
|
||||
textarea.scrollTop = Math.max(MIN_INPUT_HEIGHT, textarea.scrollHeight);
|
||||
};
|
||||
|
||||
const inputPlaceholder = t`Tell me to do something, or ask a question`;
|
||||
const placeholder = metabot.isDoingScience
|
||||
? t`Doing science...`
|
||||
: inputPlaceholder;
|
||||
|
||||
function cancelRequest() {
|
||||
dispatch(cancelInflightAgentRequests());
|
||||
}
|
||||
|
||||
const dispatch = useSdkDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(resetConversationId());
|
||||
}, [dispatch]);
|
||||
|
||||
const isOngoingConversation = metabot.messages.length > 0;
|
||||
const isPromptDefined = metabot.prompt.length > 0;
|
||||
|
||||
return (
|
||||
<Box className={Styles.container} data-testid="metabot-chat">
|
||||
<Flex
|
||||
className={cx(
|
||||
Styles.innerContainer,
|
||||
metabot.isDoingScience && Styles.innerContainerLoading,
|
||||
inputExpanded && Styles.innerContainerExpanded,
|
||||
)}
|
||||
>
|
||||
<Flex
|
||||
w="16px"
|
||||
h="16px"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
justify="center"
|
||||
align="center"
|
||||
>
|
||||
{metabot.isDoingScience ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<Icon name="ai" c="var(--mb-color-brand)" size="1rem" />
|
||||
)}
|
||||
</Flex>
|
||||
<Textarea
|
||||
id="metabot-chat-input"
|
||||
data-testid="metabot-chat-input"
|
||||
w="100%"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
ref={metabot.promptInputRef}
|
||||
autoFocus
|
||||
value={metabot.prompt}
|
||||
disabled={metabot.isDoingScience}
|
||||
className={cx(
|
||||
Styles.textarea,
|
||||
inputExpanded && Styles.textareaExpanded,
|
||||
metabot.isDoingScience && Styles.textareaLoading,
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => metabot.setPrompt(e.target.value)}
|
||||
// @ts-expect-error - undocumented API for mantine Textarea - leverages the prop from react-textarea-autosize's TextareaAutosize component
|
||||
onHeightChange={handleMaybeExpandInput}
|
||||
onKeyDown={(e) => {
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
// prevent event from inserting new line + interacting with other content
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmitInput(metabot.prompt);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{metabot.isDoingScience && (
|
||||
<UnstyledButton
|
||||
h="1rem"
|
||||
data-testid="metabot-cancel-request"
|
||||
onClick={cancelRequest}
|
||||
>
|
||||
<Tooltip label={t`Stop generation`}>
|
||||
<Icon name="stop" c="var(--mb-color-text-primary)" size="1rem" />
|
||||
</Tooltip>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
|
||||
{!metabot.isDoingScience && (
|
||||
<>
|
||||
{isOngoingConversation && !isPromptDefined && (
|
||||
<UnstyledButton
|
||||
h="1rem"
|
||||
onClick={startNewConversation}
|
||||
data-testid="metabot-new-conversation"
|
||||
>
|
||||
<Tooltip label={t`Start new chat`}>
|
||||
<Icon
|
||||
name="edit_document_outlined"
|
||||
c="var(--mb-color-text-primary)"
|
||||
size="1rem"
|
||||
/>
|
||||
</Tooltip>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
|
||||
{isPromptDefined && (
|
||||
<UnstyledButton
|
||||
h="1rem"
|
||||
onClick={resetInput}
|
||||
data-testid="metabot-close-chat"
|
||||
>
|
||||
<Icon
|
||||
name="close"
|
||||
c="var(--mb-color-text-primary)"
|
||||
size="1rem"
|
||||
/>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Stack } from "metabase/ui";
|
||||
import { Messages } from "metabase-enterprise/metabot/components/MetabotChat/MetabotChatMessage";
|
||||
import { MetabotResetLongChatButton } from "metabase-enterprise/metabot/components/MetabotChat/MetabotResetLongChatButton";
|
||||
import {
|
||||
useMetabotAgent,
|
||||
useMetabotChatHandlers,
|
||||
} from "metabase-enterprise/metabot/hooks";
|
||||
import { useMetabotReactions } from "metabase-enterprise/metabot/hooks/use-metabot-reactions";
|
||||
|
||||
export function MetabotChatHistory() {
|
||||
const metabot = useMetabotAgent();
|
||||
const { messages, errorMessages } = metabot;
|
||||
const { handleRetryMessage } = useMetabotChatHandlers();
|
||||
const { setNavigateToPath } = useMetabotReactions();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasMessages = messages.length > 0 || errorMessages.length > 0;
|
||||
|
||||
// Auto-scroll to bottom when new messages are received
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop =
|
||||
scrollContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages.length, errorMessages.length, metabot.isDoingScience]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
ref={scrollContainerRef}
|
||||
flex={1}
|
||||
gap={0}
|
||||
style={{ overflowY: "auto" }}
|
||||
p="md"
|
||||
>
|
||||
{hasMessages ? (
|
||||
<Messages
|
||||
messages={messages}
|
||||
errorMessages={errorMessages}
|
||||
onRetryMessage={handleRetryMessage}
|
||||
isDoingScience={metabot.isDoingScience}
|
||||
showFeedbackButtons={false}
|
||||
onInternalLinkClick={setNavigateToPath}
|
||||
/>
|
||||
) : null}
|
||||
{metabot.isLongConversation && <MetabotResetLongChatButton />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSdkDispatch } from "embedding-sdk-bundle/store";
|
||||
import {
|
||||
Flex,
|
||||
Icon,
|
||||
Loader,
|
||||
Menu,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "metabase/ui";
|
||||
import {
|
||||
useMetabotAgent,
|
||||
useMetabotChatHandlers,
|
||||
} from "metabase-enterprise/metabot/hooks";
|
||||
import { cancelInflightAgentRequests } from "metabase-enterprise/metabot/state";
|
||||
|
||||
import S from "./MetabotQuestion.module.css";
|
||||
|
||||
export function MetabotChatInput() {
|
||||
const metabot = useMetabotAgent();
|
||||
const { handleSubmitInput, handleResetInput } = useMetabotChatHandlers();
|
||||
const dispatch = useSdkDispatch();
|
||||
|
||||
const placeholder = metabot.isDoingScience
|
||||
? t`Doing science...`
|
||||
: t`Ask AI a question...`;
|
||||
|
||||
const cancelRequest = () => {
|
||||
dispatch(cancelInflightAgentRequests());
|
||||
};
|
||||
|
||||
const resetInput = () => {
|
||||
handleResetInput();
|
||||
};
|
||||
|
||||
const startNewConversation = () => {
|
||||
metabot.resetConversation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap="xs"
|
||||
px="md"
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ borderTop: "1px solid var(--mb-color-border)" }}
|
||||
>
|
||||
<Flex justify="center" align="center" style={{ flexShrink: 0 }}>
|
||||
{metabot.isDoingScience ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<Icon name="ai" c="var(--mb-color-brand)" size="1rem" />
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Textarea
|
||||
id="metabot-chat-input"
|
||||
data-testid="metabot-chat-input"
|
||||
w="100%"
|
||||
pb="0.2rem"
|
||||
pt="0.6rem"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
ref={metabot.promptInputRef}
|
||||
autoFocus
|
||||
value={metabot.prompt}
|
||||
disabled={metabot.isDoingScience}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => metabot.setPrompt(e.target.value)}
|
||||
classNames={{ input: S.chatInput }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isModifiedKeyPress =
|
||||
e.shiftKey || e.ctrlKey || e.metaKey || e.altKey;
|
||||
|
||||
// Prevent event from inserting new line and interacting with other content
|
||||
if (e.key === "Enter" && !isModifiedKeyPress) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmitInput(metabot.prompt);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex align="center" justify="center" gap="xs">
|
||||
{metabot.isDoingScience && (
|
||||
<UnstyledButton
|
||||
data-testid="metabot-cancel-request"
|
||||
onClick={cancelRequest}
|
||||
className={S.chatButton}
|
||||
>
|
||||
<Tooltip label={t`Stop generation`}>
|
||||
<Icon name="stop" c="var(--mb-color-text-secondary)" />
|
||||
</Tooltip>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
|
||||
{!metabot.isDoingScience && metabot.prompt.length > 0 && (
|
||||
<UnstyledButton
|
||||
onClick={resetInput}
|
||||
data-testid="metabot-close-chat"
|
||||
className={S.chatButton}
|
||||
>
|
||||
<Icon name="close" c="var(--mb-color-text-secondary)" />
|
||||
</UnstyledButton>
|
||||
)}
|
||||
|
||||
{/* Overflow menu - only shown in stacked layout and auto layout mobile */}
|
||||
<Menu position="top-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
data-testid="metabot-overflow-button"
|
||||
className={S.stackedOverflowButton}
|
||||
>
|
||||
<Icon name="ellipsis" c="var(--mb-color-text-secondary)" />
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="edit_document_outlined" size="1rem" />}
|
||||
onClick={startNewConversation}
|
||||
>
|
||||
{t`Start new chat`}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user